sander-io / hasura-jwt-auth

Hasura JWT auth using PostgreSQL
MIT License
84 stars 9 forks source link

Token refresh/renewal #7

Open richcorbs opened 5 years ago

richcorbs commented 5 years ago

Have you considered how token refresh/renewal might work using this setup?

As long as the user is actively using the system it would be ideal if they could continue to do so without having to re-auth.

Just looking for ideas if you've got them.

BTW, I have this setup running in a limited release production environment!

richcorbs commented 5 years ago

i was thinking I could have the client "ping" a refresh/renew function periodically as part of the user's activity on the site, not while they are idle.

This function would:

I've never created a postgresql stored procedure/function but it seems like the pieces are there. I may take a crack at it if I'm feeling brave.

richcorbs commented 5 years ago

Something like this maybe?

CREATE OR REPLACE FUNCTION public.refresh_token(token text)
 RETURNS TABLE(payload json, jwt_token text, valid boolean)
 LANGUAGE sql
AS $$
  SELECT
    payload::json,
    sign(
            json_build_object(
                'sub', payload->'https://hasura.io/jwt/claims'->'x-hasura-user-id'::text,
                'iss', 'Hasura-JWT-Auth',
                'iat', round(extract(epoch from now())),
                'exp', round(extract(epoch from now() + interval '24 hour')),
                'https://hasura.io/jwt/claims', json_build_object(
                    'x-hasura-user-id', payload->'https://hasura.io/jwt/claims'->'x-hasura-user-id'::text,
                    'x-hasura-default-role', payload->'https://hasura.io/jwt/claims'->'x-hasura-default-role'::text,
                    'x-hasura-allowed-roles', payload->'https://hasura.io/jwt/claims'->'x-hasura-allowed-roles'::text
                )
            ), current_setting('hasura.jwt_secret_key'))::text as jwt_token,
     valid::boolean
     FROM verify(token)
$$;

This seems to work. I just don't know how to handle it when the token isn't valid. Maybe the client does the right thing depending on the value returned for valid? Seems weak.

richcorbs commented 5 years ago

This probably isn't the best approach. Should incorporate a refresh token and return a "user" object just like authenticate does so that it can work with Hasura.

richcorbs commented 5 years ago

I think I got it:

CREATE OR REPLACE FUNCTION public.refresh_jwt(temp_token uuid, jwt text)
 RETURNS SETOF users
 LANGUAGE sql
 STABLE
AS $function$
    SELECT
        (SELECT (payload->'https://hasura.io/jwt/claims'->>'x-hasura-user-id')::uuid FROM verify(refresh_jwt.jwt, current_setting('hasura.jwt_secret_key'))) as id,
        name,
        email,
        password_hash,
        organization_id,
        temp_token,
        role,
        created_at,
        updated_at,
        enabled,
        (SELECT
            sign(
                json_build_object(
                    'sub', payload->'https://hasura.io/jwt/claims'->'x-hasura-user-id'::text,
                    'iss', 'Hasura-JWT-Auth',
                    'iat', round(extract(epoch from now())),
                    'exp', round(extract(epoch from now() + interval '24 hour')),
                    'https://hasura.io/jwt/claims', json_build_object(
                        'x-hasura-user-id', payload->'https://hasura.io/jwt/claims'->'x-hasura-user-id'::text,
                        'x-hasura-organization-id', payload->'https://hasura.io/jwt/claims'->'x-hasura-organization-id'::text,
                        'x-hasura-default-role', payload->'https://hasura.io/jwt/claims'->'x-hasura-default-role'::text,
                        'x-hasura-allowed-roles', payload->'https://hasura.io/jwt/claims'->'x-hasura-allowed-roles'::text
                    )
                ), current_setting('hasura.jwt_secret_key'))::text as jwt_token
            FROM verify(refresh_jwt.jwt, current_setting('hasura.jwt_secret_key'))) as jwt_token,
        cleartext_password
    FROM users
    WHERE users.temp_token = refresh_jwt.temp_token
    AND users.enabled = true
$function$
corepay commented 4 years ago

@richcorbs

How is this coming along for you? Ready to get started and want to lock in my auth strategy before anything else. Any adjustments or lessons learned?

I assume you have a /verify endpoint that executes this function...?

(good work, thanks for sharing)

martin-hasura commented 3 years ago

Another approach to take on this -

You could create a refresh_token field in your user table:

create table hasura_user(
    id serial primary key,
    email varchar unique,
    crypt_password varchar,
    cleartext_password varchar,
    default_role varchar default 'user',
    allowed_roles jsonb default '["user"]',
    enabled boolean default true,
    refresh_token text,
    jwt_token text
);

Then sign a refresh_token in your hasura_auth function.

Characteristics of refresh vs access tokens (jwt_token in this example) are that they're long vs short lived, and they don't have credentials (they're just used for refreshing the access token).

For this example I created a 1 week refresh token and a 5 minute access token. I also hardcoded the roles as anonymous (since Hasura is expecting some form of role, so we'll tell it the person has the most limited set of permissions).

create or replace function hasura_auth(email in varchar, cleartext_password in varchar) returns setof hasura_user as $$
    select
        id,
        email,
        crypt_password,
        cleartext_password,
        default_role,
        allowed_roles,
        enabled,
        sign(
            json_build_object(
                'sub', id::text,
                'iss', 'Hasura-JWT-Auth',
                'iat', round(extract(epoch from now())),
                'exp', round(extract(epoch from now() + interval '168 hour')),
                'https://hasura.io/jwt/claims', json_build_object(
                    'x-hasura-user-id', id::text,
                    'x-hasura-default-role', 'anonymous',
                    'x-hasura-allowed-roles', ('["anonymous"]')::jsonb
                )
            ), current_setting('hasura.jwt_secret_key')) as refresh_token,
        sign(
            json_build_object(
                'sub', id::text,
                'iss', 'Hasura-JWT-Auth',
                'iat', round(extract(epoch from now())),
                'exp', round(extract(epoch from now() + interval '5 minute')),
                'https://hasura.io/jwt/claims', json_build_object(
                    'x-hasura-user-id', id::text,
                    'x-hasura-default-role', default_role,
                    'x-hasura-allowed-roles', allowed_roles
                )
            ), current_setting('hasura.jwt_secret_key')) as jwt_token
    from hasura_user h
    where h.email = hasura_auth.email
    and h.enabled
    and h.crypt_password = hasura_encrypt_password(hasura_auth.cleartext_password, h.crypt_password);
$$ language 'sql' stable;

I can then created a hasura_refresh function. This one will rely on Hasura's sessions (you can enable passing sessions through the expanded page of the function in the Console: https://hasura.io/docs/1.0/graphql/core/schema/custom-functions.html#accessing-hasura-session-variables-in-custom-functions).

Use case: your access token (jwt_token) has expired. You come through the front-door of Hasura using your refresh token in the header. Hasura will validate the token, and use your x-hasura-user-id from the session to get you a new access token.

Note: This isn't technically a refresh_token, since generally a refresh_token would just re-sign the current token with a new exp. Here, we're actually re-generating the token - which actually has its upsides since in this model you could check every 5 minutes to make sure the user is still enabled, and their claims are still valid.

create or replace function hasura_refresh(hasura_session json) returns setof hasura_user as $$
    select
        id,
        email,
        crypt_password,
        cleartext_password,
        default_role,
        allowed_roles,
        enabled,
        '' as refresh_token,
        sign(
            json_build_object(
                'sub', id::text,
                'iss', 'Hasura-JWT-Auth',
                'iat', round(extract(epoch from now())),
                'exp', round(extract(epoch from now() + interval '5 minute')),
                'https://hasura.io/jwt/claims', json_build_object(
                    'x-hasura-user-id', id::text,
                    'x-hasura-default-role', default_role,
                    'x-hasura-allowed-roles', allowed_roles
                )
            ), current_setting('hasura.jwt_secret_key')) as jwt_token
    from hasura_user h
    where h.id = (hasura_session ->> 'x-hasura-user-id')::int
    and h.enabled
$$ language 'sql' stable;

After we've tracked everything - our request (anonymous header):

query authLogin {
  hasura_auth(args: {email: "your_email", cleartext_password: "your_password"}) {
    jwt_token
    refresh_token
  }
}

And our refresh request (refresh_token authorization header):

query refreshToken {
  hasura_refresh {
    jwt_token
  }
}