vendure-ecommerce / vendure

The commerce platform with customization in its DNA.
https://www.vendure.io
Other
5.77k stars 1.03k forks source link

Third-party authentication support #215

Closed michaelbromley closed 4 years ago

michaelbromley commented 4 years ago

Is your feature request related to a problem? Please describe. Currently Vendure allows authentication against its own user table, using a standard email/password authentication scheme.

It is not uncommon for businesses to have existing user authentication systems which they may wish to integrate into Vendure.

examples:

  1. A company uses a common auth provider such as Keycloak for all their back-office systems. They want to integrate existing user accounts with Vendure so employees can use their Keycloak credentials to sign in to the Vendure Admin UI.
  2. A company uses a CRM where it keeps customer data. It wants to integrate a new Vendure project with this existing data and allow existing customers to sign in to Vendure using their existing credentials.

Describe the solution you'd like Since there are probably many differing workflows to support here, the best solution would be something maximally flexible, such as allowing an async function to be provided in the VendureConfig.AuthOptions object which is responsible for handling the login & logout mutations.

Here's a sketch of how it might work:

interface ExternalAuthProvider {
  login(ctx: RequestContext, identifier: string, password: string, roles: Role[]): Promise<{ identifier: string; roleId: ID; } | null>;
  logout(ctx: RequestContext): Promise<boolean>;
}

const myAuthProvider: ExternalAuthProvider = {
  login: async (ctx, identifier, password) => {
    const { res } = await fetch('http://my-auth-provider/login', { identifier, password });
    const result = await res.json();
    if (result.success) {
      return {
        identifier,
        roleId: roles.find(r => r.code === CUSTOMER_ROLE_CODE).id,
      };
    }
    return null;
  },
  logout: async (ctx) => {
    const { res } = await fetch('http://my-auth-provider/logout', { identifier: ctx.activeUser.identifier });
    const result = await res.json();
    return result.success;
  },
};

const config: VendureConfig = {
  // ...
  authOptions: {
    externalAuthProvider: myAuthProvider,
  }
};
michaelbromley commented 4 years ago

Request for input

I personally do not have this use-case and I am hesitant to start designing this feature right now, because it is critical that the design is sound and also satisfies diverse requirements. Designing according to what I imagine might be needed will probably result in a sub-optimal design.

Therefore I ask that if this feature is of interest to you, please share you use-case in detail, including the desired authentication flow, what systems/API calls are involved etc. Thanks!

Szbuli commented 4 years ago

We are using an external identity system, which may be connected to several other corporate identity providers. The login process may even happen at the corporate identity provider. Vendure would only get access to a jwt token, which contains userid (email), groups (roles), and some other info. We would like to use a custom function to validate this token.

User management tasks should happen in the external identity system.

The following link under "Authentication sequence in XS-UAA" contains a diagram and a description hows this happens: https://blogs.sap.com/2017/11/16/guide-for-user-authentication-and-authorization-in-sap-cloud-platform/

michaelbromley commented 4 years ago

@Szbuli Thanks for the feedback. I'd like to dig into this workflow a bit more:

  1. What do you imagine would be the log-in flow for the Vendure Admin UI? What should happen when the unauthenticated user navigates to http://localhost:3000/admin? Should they see a "log in with " button rather than the username/password inputs?
  2. In the diagram you linked, I am assuming the Vendure server would be at the far right [Application (Resouce Server)]. Is that correct?
  3. "User management tasks should happen in the external identity system" - so there should be no CRUD actions allowed on Administrators then? Well, just Read may still make sense.
Szbuli commented 4 years ago
  1. Users could only access the admin ui through a router, which would handle authentication.
  2. yes
  3. I am not sure yet how this should work. There is a possibility that vendure should store extra information for the users in the idm.
michaelbromley commented 4 years ago

@Szbuli When the router allows access to the Vendure server, is the JWT in the header as a Bearer token or in a cookie?

Szbuli commented 4 years ago

@michaelbromley as a bearer token

michaelbromley commented 4 years ago

I'm currently looking at the Nest.js passport.js integration. Originally I did use the Passport module with JWT for auth, but I eventually removed it because certain aspects were quite complex for what I needed at the time (see #25).

The main advantage I see with using it is that it would then open up Vendure to making use of the many available Strategies designed to work with passport.js. On the other hand I want to be really certain that it solves the problem better than what we already have or could more simply achieve with something like the original proposal. Also the passport integration would impose certain structural patterns which I might not otherwise choose.


Another aspect which @Szbuli's use-case highlights is the need to be able to custom authenticate any GraphQL operation, not just the login mutation.

Example:

  1. User Joe authenticates with existing corporate 3rd party auth gateway and gets a JWT.
  2. Gateway then redirects to http://localhost:3000/admin/, at which point Joe expects to see the dashboard, not another login page.
  3. So when Joe hits the Vendure dashboard, some GraphQL requests will fire for the latest orders data or whatever.
  4. This GraphQL request will hit the existing AuthGuard middleware which will see that Joe has no Vendure session. So it will kick him out.

Therefore, we'd need our custom auth solution to be able to define a function verify() which is called by the AuthGuard whenever the request is found to have no valid Session. This function can then perform the check with the 3rd-party auth provider and return the data that would be then used to create a new Session, at which point the request can continue successfully. All subsequent requests now have a valid Vendure Session and do not need to invoke the verify function.

This verify function would actually replace the login function defined above, as it would be used both when logging in via the login mutation and in the situation described in the previous paragraph.

michaelbromley commented 4 years ago

@Szbuli I've been thinking more about this flow and trying to recreate a comparable test case locally using Keycloak:

We are using an external identity system, which may be connected to several other corporate identity providers. The login process may even happen at the corporate identity provider. Vendure would only get access to a jwt token, which contains userid (email), groups (roles), and some other info. We would like to use a custom function to validate this token.

What I run into so far is that the token is made available to the Admin UI app, but then the Admin UI app makes an initial request to the GraphQL API and has no way of knowing that it should forward the token. Therefore your JWT would not even make it to the GraphQL API and to any custom auth logic.

So I then got thinking about if there is another way to solve this just using the existing Vendure plugin capabilities. Something like this:

  1. Create a plugin which exposes a custom REST endpoint, e.g. /verify
  2. Configure your router to point at this endpoint when directing users to the Vendure Admin UI.
  3. This endpoint will receive the token in the request, and can perform whatever validation you needed at this stage. Also, you can use the TypeORM connection object in this VerificationController to create a new User, Administrator and AuthenticatedSession and set a Vendure session cookie (see here for an example of this)
  4. Assuming the validation passes and the new session is created, the /verify endpoint returns a redirect to the Admin UI app, and now a valid session cookie is set and the Admin UI should be accessible.

Does that approach make sense in your case?

Szbuli commented 4 years ago

In our case the client always uses a destination to reach the backend. By using the destination, it will contain the necessary token. Is it possible to deploy the admin UI as a separate static html app?

You can look here for a hello world using express + passport with XSUAA. The frontend apps are using the approuter to access the backend. The jwt is automatically set when using the approuter destination (after a successful login).

mdagit commented 4 years ago

Just to stir the pot, here are some other observations:

michaelbromley commented 4 years ago

@mdagit thank you, those are all really useful points to consider 👍

michaelbromley commented 4 years ago

Note to self: consider the pros and cons of integrating with an existing pluggable auth solution such as https://github.com/accounts-js/accounts or passport.js

agustif commented 4 years ago

Note to self: consider the pros and cons of integrating with an existing pluggable auth solution such as https://github.com/accounts-js/accounts or passport.js

If you're still considering this, I'm trying to get AccountsJS Oauth support/examples/docs up to date, and planning on doing the same trying to maintain the typeorm-postgres-transport package. (which recently got fixed by someone else on a github PR merged in release 0.0.27)!

I would love to help with this feature/plugin, if you could guide me/help me out on any roadbloacks I might encounter.

Let me know what you think

Also a big Multi-Factor-Auth refactor is on the works by leo, and I know david has a working Magic Link like implementation.

Also OTP is working on the examples.

All of this could be a great addition/alternative to the regular ol' email/password login which will probably be a great option for at least 80% of the users of vendure

michaelbromley commented 4 years ago

I've started a proof-of-concept implementation of the following design and so far have all existing tests passing. Here's an overview of the design and a note on some use-case coverage:

  1. There are 2 new (optional) properties of the authOptions:
    export interface AuthOptions {
      // ...
      shopAuthenticationStrategy?: AuthenticationStrategy[];
      adminAuthenticationStrategy?: AuthenticationStrategy[];
    }

    These new properties allow you to specify an array of AuthenticationStrategy objects (see next point) which define the way(s) in which a user (Customer or Administrator) may authenticate themselves. By default this array contains just the NativeAuthenticationStrategy which implements the current DB-stored username/hashed password logic.

  2. The AuthenticationStrategy interface looks like this:

    export interface AuthenticationStrategy<Data = unknown> extends InjectableStrategy {
      readonly name: string;
    
      /**
       * Used to validate the data payload provided to the `authenticate`
       * mutation. Since different strategies will require different types of data,
       * we must perform this validation at run-time whilst leaving the "data" input
       * as a generic JSON type.
       */
      validateData(data: unknown): data is Data;
    
      /**
       * Used to authenticate a user with the authentication provider.
       */
      authenticate(ctx: RequestContext, data: Data): Promise<User | false>;
    
      /**
       * Called when a user logs out, and may perform any required tasks
       * related to the user logging out.
       */
      onLogOut?(user: User): Promise<void>;
    } 
  3. A new mutation has been created to allow authenticating via one of the configured strategies:
    type Mutation {
      authenticate(method: String!, data: JSON!, rememberMe: Boolean): LoginResult!
    }

    The existing login mutation becomes a pre-configured alias of this new mutation, and would be deprecated.

  4. Authentication logic goes like this:
    1. Get all configured AuthenticationStrategies for the current API (Shop or Admin)
    2. Find one whose name property matches the method arg of the "authenticate" mutation. Throw if none found.
    3. Validate the data payload by calling authenticationStrategy.validateData(args.data);. Throw if return value is false.
    4. Execute the authenticationStrategy.authenticate(args.data) method which should return a User entity on success, or false on failure. As an example, the NativeAuthenticationStrategy currently looks like this:
      async authenticate(ctx: RequestContext, data: NativeAuthenticationData): Promise<User | false> {
      const user = await this.getUserFromIdentifier(data.username);
      const passwordMatch = await this.verifyUserPassword(user.id, data.password);
      if (!passwordMatch) {
          return false;
      }
      return user;
      }
  5. A new entity has been defined, AuthenticationMethod, which is responsible for storing data related to a specific AuthenticationStrategy for a given User:

    // before
    class User {
      identifier: string;
      passwordHash: string;
    }
    
    // after
    class User {
      identifier: string;
      authenticationMethod: AuthenticationMethod[];
    }
    
    class AuthenticationMethod {
      username: string;
      passwordHash: string;
    }

    This allows a given User to be associated with multiple auth providers, e.g. having both Facebook and Google logins. There are 2 types of AuthenticationMethod sub-classes: NativeAuthenticationMethod and ExternalAuthenticationMethod. The former is for the "default" DB-stored username/password strategy, and contains fields needed to implement things like email address verification & password resets etc. The latter is for things like Facebook login and can hold arbitrary data needed by the specific APIs of that auth provider.

Use-case: default username/password auth

As mentioned, this use-case (the current default and only method of auth) is implemented and fully working in the proof-of-concept.

Use-case: social login

Prior art: https://github.com/FlushBG/vendure-social-auth

Not yet tried this, but here's how it should work (based on the social auth plugin linked above):

  1. Storefront implements e.g. the Facebook SDK login flow, which results in a "token" being issued for that Facebook user.
  2. The FacebookAuthenticationStrategy.authenticate() method expects this token as the data payload. It then makes a call to a Facebook API which returns some data for that user (facebook id, email address, name). We use the facebook-specific id to lookup any existing user with an ExternalAuthenticationMethod featuring this facebook id. If found, return that user. They are now logged in to Vendure.
  3. If no existing User is found, we create a new User, with a corresponding ExternalAuthenticationMethod in which we store the facebook id. We also create a new Customer, associate it with the User, and return the User. The user is now logged in to Vendure with a newly-created Customer account.

Use-case: SSO

Let's say we have an existing corporate ID provider and we want our administrators to authenticate using their existing company-wide SSO (single sign-on) identity. This is probably the most complex use-case, since we need to think about how to authenticate into the Admin UI app as well as how to assign the correct Roles to those administrator users. I will explore this more as I work through this design.

FlushBG commented 4 years ago

That definitely sounds like a solution to the issue! I will do some research on the SSO part, since I am not sure if any tokens are involved in the active directory authentication process. On the other hand, I would recommend against having an unknown data type, but probably follow some structure that is either an username/password object, or a token object. We could probably check how Passport.js handles the different auth options, since you are able to write your own strategy on top of their base. Maybe the blueprint will be useful. Great work on the design!

michaelbromley commented 4 years ago

@FlushBG yeah I'm not really overjoyed by having an untyped data input, but unfortunately the graphql spec does not permit union input types, which would be perfect here.

So to enable different types of object as the input we could either:

  1. define an input type that has optional fields for the anticipated data:
    input AuthenticateInput {
      username: String
      password: String
      token: String
    }

    This is better than the JSON type, but still not fully type-safe, since there's nothing to stop someone invoking it with an object like { password: "foo", token: "bar" } which would not be the expected type of object for either the native nor external strategies.

  2. define a number of different possible input types:

    input CredentialsInput {
      username: String!
      password: String!
    }
    input TokenInput {
      token: String!
    }
    
    extend type Mutation {
      authenticate(method: String!, credentials: CredentialsInput, token: TokenInput): LoginResult
    }

    This is still not fully type safe, since you could e.g. invoke it with the "native" method and "token" input, which is incorrect. It also does not allow any other type of input, which may or may not be an issue. I'm not yet sure what other types of input data may be needed by other auth providers.

FlushBG commented 4 years ago

@michaelbromley I remember doing heavy research on the union input types when I was building the plugin, it's a real bummer that they are not supported. What could be done is split the native and external mutations, and have them receive different input types. Not the best solution, especially if we are aiming for a generic authentication mechanism, but pretty type-safe nonetheless!

michaelbromley commented 4 years ago

Nope, don't want to split into separate mutations for each strategy (though I also did consider that briefly).

Another idea would be to have the AuthenticationStrategy define its own expected input type as SDL, and then use that at bootstrap-time to create a keyed input type:

export class NativeAuthenticationStrategy implements AuthenticationStrategy {
  name: 'native';

  defineInput(): DocumentNode {
    return gql`
      input CredentialsInput {
        username: String!
        password: String!
      }
    `;
  }

  // ...
}

Then on bootstrap we execute the defineInput method, and merge that type into a parent Input object so the final schema looks like:

input AuthenticationInput {
  native: CredentialsInput;
}

extend type Mutation {
  authenticate(method: AuthenticationMethod!, input: AuthenticationInput): LoginResult
}

If we go with this idea, we could even _drop the method arg` from the mutation, and call it like:

authenticate(input: {
  facebook: { token: "foo" }
}) {
  # ...
}

We would just have to enforce at run-time that only a single key of the AuthenticationInput be passed at once.