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

Third-party authentication support #215

Closed
michaelbromley opened this issue Nov 26, 2019 · 18 comments
Closed

Third-party authentication support #215

michaelbromley opened this issue Nov 26, 2019 · 18 comments
Labels
design 📐 This issue deals with high-level design of a feature type: feature ✨ @vendure/core
Milestone

Comments

@michaelbromley
Copy link
Member

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
Copy link
Member Author

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!

@michaelbromley michaelbromley added the design 📐 This issue deals with high-level design of a feature label Dec 3, 2019
@Szbuli
Copy link

Szbuli commented Dec 5, 2019

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
Copy link
Member Author

@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
Copy link

Szbuli commented Dec 7, 2019

  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
Copy link
Member Author

@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
Copy link

Szbuli commented Dec 10, 2019

@michaelbromley as a bearer token

@michaelbromley
Copy link
Member Author

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
Copy link
Member Author

@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
Copy link

Szbuli commented Dec 17, 2019

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
Copy link

mdagit commented Jan 16, 2020

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

  • Some developers may want an entirely separate authentication provider for customers (store frontend) vs admin ui. For the admin ui, the builtin (local) Vendura provider might be sufficient; for a customer frontend, it will generally be desired to support external aka "social" providers. The frontends and backend might still share a single "user" table of course, but the data model has to be very clear to have separate attributes for email address, external authenticated id, and internal surrogate id.

  • Some frontends will want to force customers to create an account as part of checkout, some will want it to be optional, and some won't want to bother customers with it.

  • Probably some sites will want integration with some ipaas like AWS Cognito or Okta or Firebase (particularly if they are mobile focused).

  • For tech support, impersonation is often useful, but is tricky to get right. This would mean that some staff person authenticates to admin ui and then gets themselves redirected to the front end with a JWT which impersonates a selected customer. The JWT payload might include the underlying real user, for logging purposes.

  • When OIDC is part of the external authentication provider protocol, then some configurable set of user attributes might come back in the payload, most particularly the human name, and the email address. (In OIDC and OAuth's confusing terminology, this is called the "scope"). This information
    then needs to be automatically populated into the new "user" record.

  • Architecting authorization is in some ways thornier than authentication. The GraphQL standard has nothing to say about it. Some projects have introduced special directives at the GraphQL layer akin to JAX-RS annotations. Other projects deal with it below the GraphQL layer, since the representation of the permissions in a RBAC system might not map conveniently to the mutations and queries and types at the GraphQL layer.

  • The whole question as to whether a JWT token constitutes a reasonable solution for session management is a whole separate debate, which has both theoretical and practical aspects, such as whether to attempt to support global revocation support in a SSO system, and whether to entirely delegate choice of session lifetime to an outside party (which is what can happen if an externally created JWT is used as a session token).

@michaelbromley
Copy link
Member Author

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

@michaelbromley
Copy link
Member Author

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

@michaelbromley michaelbromley added this to the v1.0.0 milestone Apr 23, 2020
@agustif
Copy link
Contributor

agustif commented Jun 5, 2020

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 michaelbromley modified the milestones: v1.0.0, v0.14.0 Jun 18, 2020
@michaelbromley michaelbromley pinned this issue Jun 18, 2020
@michaelbromley
Copy link
Member Author

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
Copy link

FlushBG commented Jun 22, 2020

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
Copy link
Member Author

@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
Copy link

FlushBG commented Jun 23, 2020

@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
Copy link
Member Author

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.

michaelbromley added a commit that referenced this issue Jun 24, 2020
Relates to #215. In this commit we are adding a new AuthenticationMethod entity, which stores authentication information for a given auth provider. The default username/password strategy is known as the "Native" strategy.

This refactor does not add any new functionality, but sets up support for external auth providers whilst making sure all existing e2e tests pass.

BREAKING CHANGE: A new `AuthenticationMethod` entity has been added, with a one-to-many relation to the existing User entities. Several properties that were formerly part of the User entity have now moved to the `AuthenticationMethod` entity. Upgrading with therefore require a careful database migration to ensure that no data is lost. On release, a migration script will be provided for this.
@michaelbromley michaelbromley unpinned this issue Jul 21, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
design 📐 This issue deals with high-level design of a feature type: feature ✨ @vendure/core
Projects
None yet
Development

No branches or pull requests

5 participants