Closed michaelbromley closed 4 years ago
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!
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/
@Szbuli Thanks for the feedback. I'd like to dig into this workflow a bit more:
http://localhost:3000/admin
? Should they see a "log in with @Szbuli When the router allows access to the Vendure server, is the JWT in the header as a Bearer token or in a cookie?
@michaelbromley as a bearer token
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:
http://localhost:3000/admin/
, at which point Joe expects to see the dashboard, not another login page.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.
@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:
/verify
VerificationController
to create a new User
, Administrator
and AuthenticatedSession
and set a Vendure session cookie (see here for an example of this)/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?
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).
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).
@mdagit thank you, those are all really useful points to consider 👍
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
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
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:
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.
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>;
}
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.
name
property matches the method
arg of the "authenticate" mutation. Throw if none found.authenticationStrategy.validateData(args.data);
. Throw if return value is false.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;
}
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.
As mentioned, this use-case (the current default and only method of auth) is implemented and fully working in the proof-of-concept.
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):
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.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.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.
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!
@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:
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.
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.
@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!
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.
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:
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 thelogin
&logout
mutations.Here's a sketch of how it might work: