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

Custom JWT Claims #49

Closed
beepsoft opened this issue Nov 27, 2021 · 26 comments · Fixed by #78
Closed

Custom JWT Claims #49

beepsoft opened this issue Nov 27, 2021 · 26 comments · Fixed by #78
Labels
enhancement New feature or request priority: low

Comments

@beepsoft
Copy link

nhost and HBP used to support custom JWT claim, however hasura-auth doesn't support them. As @elitan wrote: "For JWT custom fields, we've temporary dropped support for it in v2 because we don't want users to edit the new auth.users table. Everything in the auth schema is managed by Hasura Auth."

I would like to suggest an alternative solution, which would not require us developers to touch auth.users and would not require hasura-auth to have any knowledge of the developers schema and tables.

I would suggest to have a nhost configuration option, which allows us to define a hasura query for custom claim values. A separate query for each claim. The query should have a single $userId: uuid! parameter and the query result, whatever it is - a single value or a complete json - would be set in the custom claim field.

For example:

auth:
  custom_claims: 
      organization-id: >
        query customClaim($userId: uuid!) {
          profiles(where:{userId:{eq:{$userId}}}) {
            organizationId
          }
        }
      service-account-id: >
        query customClaim($userId: uuid!) {
          profiles(where:{userId:{eq:{$userId}}}) {
            serviceAccountId
          }
        }

This would result in a x-hasura-organization-id: 123 and a x-hasura-service-account-id: 456 custom claim in the JWT.

This solution would not require accessing/modifying auth.user by the developers and hasura-auth should not take care of any specific tables in the public schema: it is the duty of the developer to query and return an appropriate value from its own schema based on a $userId.

@kratam
Copy link

kratam commented Nov 29, 2021

It would make the migration to nhost2 a lot easier since my current permissions rely on custom session variables.

And don't forget that hasura only allows static and from session variable options for column presets:
Screenshot 2021-11-29 at 9 02 26

@beepsoft
Copy link
Author

It would make the migration to nhost2 a lot easier since my current permissions rely on custom session variables.

Yes, that was my biggest pain point as well. I had two custom fields, which contained some calculated IDs. Now I have to navigate with 2-3 joins down in my permission queries to get to auth.user and do a userId: {_eq:{xhasura-user-id}} condition. Practically you can only use xhasura-user-id and so you always have to navigate to the auth.user for this.

@elitan
Copy link
Contributor

elitan commented Nov 29, 2021

I agree with all this, to be able to set custom claims is important.

Here are the current ideas we can continue to discuss to find a good solution:

1 Custom GraphQL query

@beepsoft's idea to provide a custom GraphQL query for Hasura Auth to execute when generating the JWT token.

Can it be an issue that the query can return multiple rows?

2. Special profiles table + settings

We could decide that public.profiles is a "special" table, still controlled by the developer, that could be used for custom claims. This could be done by linking public.profles and auth.users with something like this:

public.profiles
- id
- user_id FK to auth.user_id
- group_id (custom column)

Hasura Auth could then accept an env var to use group_id as a custom claim.

The problem with this solution is that it adds a lot of responsibility to the developer. The profiles table has to be in sync with the auth.users table.

3. Webhook

We could define a webhook to a Nhost Function that takes the users's id as input and return the custom claims that should be added to the JWT token.

This solution is very flexible and claims can be created from other data-sources and developers are not limited by Postgres to generate claims.

@beepsoft
Copy link
Author

beepsoft commented Nov 29, 2021

Which solution is the best is a question of tradeoffs, as always 😀

  • webhook is the most flexible, but you need to write a serverless function to generate the claim
  • special profile table: this is the most opinionated. The developer has to define their schema in accordance to nhost requirements, but doesn't need a serverless function to operate
  • graphql query: this is between the two, I think. We don't need to write/host a function to generate the claim only a graphql query, but it is already a given by Hasura. Also, we can use any of our tables and fields (even calculated fields, or remote schemas, or maybe Hasura actions if we need more logic) to generate the claims.

Can it be an issue that the query can return multiple rows?

I haven't really thought about it, but practically the graphql should result in a single scalar value as this is what we can use in the permission queries later. However, there maybe cases where the claim is not to be used only in Hasura permissions queries in which case it would be nice to allow users to store whatever is returned by the graphql queries.

@artistic-differences
Copy link

Is there any timeline for when custom JWT will be available in nhost v2?
I like the webhook approach best. It fits with AWS Cognito User workflow which, after use sign in, allows a custom function to be run to add the additional claim details into the generated JWT. https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-pre-token-generation.html

@elitan
Copy link
Contributor

elitan commented Jan 15, 2022

@artistic-differences No timeline as of now. We first need to decide on the optimal solution. THanks for pointing out how AWS Cognito handles this. 👍

Another alternative that Firebase uses:

Reference: https://firebase.google.com/docs/auth/admin/custom-claims

From my understanding, you can set claims to a user and those claims stay the way they are until they are changed. No extra data fetching request every time a JWT token is created.

To do this, we could add a new column to the users table called customClaims which is a JSONB data type. All custom claims would be added to that column.

Then when Hasura Auth creates a new JWT token it will fetch custom claims from the user's customClaims column and add them (if any) as custom claims to the JWT token.

The downside of this is that the customClaims is unstructured and there is no data integrity in place.

@artistic-differences
Copy link

Wow quick feedback indeed.

For a different project we use AWS Cognito and Hasura add add these claims as highlighted. Just evaluating nhost against that. One of the main claims is orgainsation_id which we use for multi-tenant permissioning within the app. I.E. Nearly all tables have organisation_id and all tables are secured again JWT.x-hasura-organisation-id to ensure a tenant only has access to their data.
I think the customClaim JSONB could be used to achieve the same goal.
As there is no time frame for this custom JWT enhancement, however I think there is wide agreement looking through the discord channel that something is needed. Could I suggest that in the meantime you add a ENV flag to allow us to Toggle between JWT Authentication and Webhook auth. This is supported as an alternative authentication stream in Hasura and as you now have co-located server less functions this might be a good alternative. ENV flag could then be turned back to JWT when this is released. In the meantime it would permissions could be set to same session variables and we wouldn't have to revisit this.
Interesting to hear your thoughts on this.

@elitan
Copy link
Contributor

elitan commented Jan 15, 2022

Yes there is a wide agreement on a solution for this and we do want this in place as soon as possible. This is a prioritized issue for us. Unfortunately, I don't expect us to do some quick fix like the webhook auth alternative you mentioned. We just have too much other stuff going on right now.

To keep everyone in this thread on the same page here's the different alternatives we've discussed:

  • Webhook returning the custom claims for a given user's id
  • Special profiles table in the public schema linking a user to a profile
  • GraphQL Query
  • customClaims column (new) on the users table.

Given that claims don't change very often I kind of leaning towards the customClaims column similar to how Firebase is doing it. The downside is the unstructured data and that we're losing data integrity. However, the customClaims column is very flexible and can be updated with values from inside and outside the database.

@elitan elitan changed the title Custom JWT claims via graphql queries Custom JWT Claims Jan 15, 2022
@artistic-differences
Copy link

Just to be clear if you go down the customClaims column route. When this column is converted into a JWT the JSONB needs to parsed and split into key, value pairs.

I.E
Column customClaims: "{ claim1 : claimValue1, claim2 : claimValue2 }"

needs to become separate key value pairs that are then available for use in Hasura for linking through to permissions
i.e.
{"id":{"_eq":"x-hasura-custom-claim1"}}

and not left as

 {"id":{"_eq":"x-hasura-customclaims"}} which cannot be de-referenced in Hasura

Hope that makes sense

@ArieJones
Copy link

ArieJones commented Jan 18, 2022

Sorry just now catching up on this ... but could we just use an existing HASURA hook to a custom webhook.. as I put in this issue here..

(nhost/hasura-backend-plus#643)

And if the HASURA_GRAPHQL_AUTH_HOOK environment variable is already in use.. then adding an NHOST_GRAPHQL_CUSTOM_AUTH_HOOK environment variable and if set it would call that web hook as well

Yes we would have to write the function to take care of sending back the custom claims.. but you would be unrestricted basically in what graphql query and/or custom logic that could be applied.

It would also allow and easier path forward for users to address more complex scenarios without the need of the NHost team to have to configure additional logic or complex parsing solutions

Further info https://hasura.io/docs/latest/graphql/core/auth/authentication/webhook.html

@beepsoft
Copy link
Author

Isn't the Hasura auth webhook gets called for every graphql query? This doesn't seem to scale well.

@ArieJones
Copy link

It might be in some scenarios cause issues.

If sticking with JWT .... if you add an NHOST_CUSTOM_AUTH_ACTION we could possibly look at updating this

export function generatePermissionVariables(user: UserFieldsFragment): {

to include a call out to a Hasura action passing in the userId ... would remove the need to know within auth the location of the users custom api setup ..

It would just need to return the array of key:value pairs for the custom claims.

I may try to see if I can pull down the code and maybe do a simple example.... and possibly put up a PR ...

@plmercereau
Copy link
Contributor

plmercereau commented Jan 19, 2022

Prototyping:

  1. On startup, hasura-auth:
    • checks if a public.profiles tables exists. If not, it creates it. Only one column is required: id, of type uuid, both primary key and foreign key that references auth.profile.id
    • checks if a profile object relationship exists in the auth.users table. If not it creates it.
    • if public.profiles exists, it checks every column is nullable or has a default value. If required, it sets columns to nullable. It prevents a possible insert error on registration (step 4)

The profile encourages the users not to change anything in the auth schema and metadata.

  1. the developer adds their own columns to profile, creates other public tables, relationships, permissions, etc.

In this example, an additional column public.profiles.first_name and a public.groups table are created, with the corresponding user.profile.group object relationship

  1. Configuration of the custom claims:
{ 
  'group-id': 'profile.group.id',
  'first-name': 'profile.first_name'
}

The custom claim templates follow a basic but extensible Jsonata syntax.
The backend generates the following GraphQL query from the config:

query ($userId: uuid!) {
    user (id: $userId) {
        profile {
            group {
                id
            }
            first_name
        }
    }
}
  1. On registration, an 'empty' profile record is created with the same id as the user

  1. When the backend generates the JWT:
  • the query is executed and picks the user data (result.data.user):
{
  profile: {
    group: { id: '07b6e25e-20ac-4036-b6ae-cba1ad02e845' },
    first_name: 'Pilou'
  }
}
  • It evaluates each expression with the above, and transforms the keys:
{
  'x-hasura-group-id': '07b6e25e-20ac-4036-b6ae-cba1ad02e845',
  'x-hasura-first-name': 'Pilou'
}
  • It adds these claims to the JWT

Note: maybe profile is poorly named. Happy to hear better suggestions e.g. user_data, user_info...

@plmercereau
Copy link
Contributor

This commit illustrates the concept

@plmercereau plmercereau added enhancement New feature or request priority: high labels Jan 19, 2022
@elitan
Copy link
Contributor

elitan commented Jan 21, 2022

I'll just add my thoughts after some thinking.

I generally like the customClaims approach most. Here are my reasons:

  • It's a simple solution (easy for users to understand, easy to write about in our docs)
  • It's simple to update the claims.
  • It's simple for us to always make work, instead of throwing errors due to incorrect configurations. E.g. if a key does not follow the correct format (String), we discard it.
  • We lose data integrity, yes, but if a claim is incorrect due to no data integrity, I don't see that leading to any security issues with permissions.

@beepsoft
Copy link
Author

beepsoft commented Jan 21, 2022

So, the customClaims would be a field on auth.users right?

When would it be set?

Should we have an event trigger on auth.users so that when a new user is registered via nhost-auth Hasura calls the event handler where we generate the JSON(?) in customClaims. This is fine with me, the only problem is that when a call to /signup/email-password returns, the user's customClaims would not be ready right away, as event handler is called async, and so the accessToken returned by /signup/email-password would not contain the customClaims settings.

@elitan
Copy link
Contributor

elitan commented Jan 21, 2022

So, the customClaims would be a field on auth.users right?

Yes, that was the idea.

When would it be set?

Good question. I think we can't allow claims to be arbitrarily set during sign-up (security issues). So all custom claims should be set post sign up. Either via event triggers, manually, or something else.

BUT

After discussing with @plmercereau the approach of specifying something like:

{
  "email": "email",
  "group-id": "profile.group.id",
  "organization-id": "organization.id"
}

and having Hasura Auth get the values via GraphQL relationships (starting from the user in the users table) is also appealing.

We (mostly @plmercereau 😅 ) will do more investigation :)

@kratam
Copy link

kratam commented Jan 21, 2022

+1 for the webhook approach (input: user's id (or the entire user row with email and id), output: custom claims json).

Apart from being the most flexible, it's pretty common (e.g. cognito and auth0 both use something similar).

@beepsoft
Copy link
Author

BUT

After discussing with @plmercereau the approach of specifying something like:

{
  "email": "email",
  "group-id": "profile.group.id",
  "organization-id": "organization.id"
}

and having Hasura Auth get the values via GraphQL relationships (starting from the user in the users table) is also appealing.

OK, so eventually you will do a GraphQL (Hasura) query to collect the claim data, right? And this is pretty much what was my original proposal as well. 😃 Except that in your case it will be much more controlled and will look for just specific fields, but you will have the same issues as running a user defined GraphQL query (run a query, handle errors, timeouts, etc.). And if that's the case, then we could actually have both solutions for the same price: the controlled approach as above for those, who don't want to bother with writing GraphQL or a free form GraphQL query for advanced use cases.

And again: a free form GraphQL query in this case can also be considered much like a webhook as well considering that you can now defined an action transform, which can call any REST endpoints.

@elitan
Copy link
Contributor

elitan commented Jan 21, 2022

@kratam Good input! I think this is the reference from Auth0: https://auth0.com/docs/get-started/authentication-and-authorization-flow/customize-tokens-using-hooks-with-client-credentials-flow

@beepsoft Not saying we'll do one or the other :>. Just that it was appealing :D

@beepsoft
Copy link
Author

@elitan I will be happy with any solution at the end, don't get me wrong. 😃 I just really think that the GraphQL approach is the most versatile and has no worse performance or implementation impact than any other solution.

For the syntax I may update my proposal this way, so that it would not include the full query and these claim queries could be merged into a single GraphQL query easier.

auth:
  custom_claims: 
      x-hasura-organization-id: >
          profiles(where:{userId:{_eq:$userId}}) {
              organizationId
          }
      x-hasura-service-account-id: >
          profiles(where:{userId:{_eq:$userId}}) {
              serviceAccountId
          }

The Hasura query generated (need to convert kebab-case to snake_case for graphql aliases):

query customClaims($userId: uuid!) {
  x_hasura_organization_id: profiles(where:{userId:{_eq:$userId}}) {
    organizationId
  }
  
  x_hasura_service_account_id: profiles(where:{userId:{_eq:$userId}}) {
    serviceAccountId
  }
}

And the result would be:

{
  "data": {
    "x_hasura_organization_id": [
      {
        "organizationId": "123"
      }
    ],
    "x_hasura_service_account_id": [
      {
        "serviceAccountId": "456"
      }
    ]
  }
}

And here we would just need to parse to leaf values of the JSON ("123", "456") and convert back the claim names from snake_case to kebab-case, and done.

Of course there would be need for some sanity check regarding the graphql expressions (eg. syntax).

@elitan
Copy link
Contributor

elitan commented Jan 23, 2022

I'll just highlight this point from @beepsoft

And again: a free form GraphQL query in this case can also be considered much like a webhook as well considering that you can now defined an action transform, which can call any REST endpoints.

I just want to highlight that this is a top priority issue for us. Given that there are a few different possible solutions we want to take our time to evaluate what approach would suit best.

We'll update you with learnings and insights during our process and hope for the same kind of valuable feedback :)

plmercereau added a commit that referenced this issue Jan 24, 2022
Introduces a `AUTH_JWT_CUSTOM_CLAIMS`. Attaches custom `x-hasura-` JWT claims in querying data
related to the current user e.g. user.organisation.id. `AUTH_USER_SESSION_VARIABLE_FIELDS` is an
equivalent and is deprecated.

closes #49
@plmercereau plmercereau linked a pull request Jan 24, 2022 that will close this issue
plmercereau added a commit that referenced this issue Jan 27, 2022
Introduces a `AUTH_JWT_CUSTOM_CLAIMS`. Attaches custom `x-hasura-` JWT claims in querying data
related to the current user e.g. user.organisation.id. `AUTH_USER_SESSION_VARIABLE_FIELDS` is an
equivalent and is deprecated.

closes #49
@plmercereau
Copy link
Contributor

plmercereau commented Jan 27, 2022

@beepsoft the PR simplifies your example to:

{
  "x-hasura-organization-id": "profile.organisationId",
   "x-hasura-service-account-id": "profile.serviceAccountId"
}

GraphQL query is automatically generated and result is transformed with JSONata. In this case, no need to set a webhook nor to code a graphql query, or to transform the data fetched from this query. What you would only need is to create the profile table and define the right user.profile object relationship in Hasura.

I agree a webhook would offer more flexibility. Yes, webhooks are used by other competitors. But we also have to keep in mind that hasura-auth is a side-car to Hasura (while our competitors don't have this asset):

  • the main aim here is to send claims back to the Hasura permissions system
  • we assume user permissions data is stored in the database

This offers us an opportunity to make things way simpler that in other architectures, and we don't want to create an additional dependency to an external service (the webhook) unless it is strictly necessary.

The webhook approach could be a "plan B" approach when our implementation is not enough, hence I'm reopening the issue and rename it so we can continue our discussion.
I however decrease the priority of the issue, as we expect our current implementation will work in most common cases (the ones mentioned so far).

@plmercereau plmercereau reopened this Jan 27, 2022
@plmercereau plmercereau changed the title Custom JWT Claims Webhook to extend custom JWT Claims Jan 27, 2022
@beepsoft
Copy link
Author

Thanks @plmercereau, I actually like your approach! And you are right, it is semantically the same as was my suggestion but in a more controlled way. My gut feeling is that we will need more flexibility in the future in case the claim calculation cannot be statically declared and need to involve an external system.

Anyways, I'm very happy to have custom claims back! 😃

@kratam
Copy link

kratam commented Jan 27, 2022

I however decrease the priority of the issue, as we expect our current implementation will work in most common cases (the ones mentioned so far).

I just want to highlight the current approach:

  • requires us to change every permission rule in the app (since they currently use a custom x-hasura-* header) - this stops me from upgrading the nhost v2
  • blocks the "column presets" feature of the insert permissions since you can only use session variables there (e.g. auto-populate a column with the current user's group_id upon insert)

@elitan elitan changed the title Webhook to extend custom JWT Claims Custom JWT Claims Mar 2, 2022
@elitan
Copy link
Contributor

elitan commented Mar 2, 2022

To original "Custom JWT Claims" issue is fixed and released.

@elitan elitan closed this as completed Mar 2, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request priority: low
Projects
None yet
Development

Successfully merging a pull request may close this issue.

6 participants