A Postgraphile plugin for HAPI.
npm install hapi-postgraphile
Here is a sample, minimal config using values that work with this tutorial. Yours will be different.
const server = hapi.server({port: 5000});
await server.register({
plugin,
options: {
pgConfig: 'postgresql://user@localhost/db',
schemaName: 'forum_example',
schemaOptions: {
jwtSecret: 'keyboard_kitten',
jwtPgTypeIdentifier: 'forum_example.jwt_token',
pgDefaultRole: 'forum_example_anonymous'
}
}
});
This module exposes one endpoint, by default at /graphql
. This endpoint will accept GraphQL queries, mutations, and will read an Authorization
header with a Bearer <jwtToken>
value.
You should be able to walk through the excellent schema design tutorial here and use this endpoint for all of the requests using a tool like GraphiQL
.
All of the options documented here are passed through to the createPostGraphileSchema
function when provided in the schemaOptions
config property.
hapi-postgraphile
can take advantage of your server cache. You will need to set up the cacheConfig parameters you pass to the plugin, and declare a list of allowed operation names.
Caching in this way, via the simple key/val store is very limited and can only cache queries using default options, and cannot cash requests requiring JWT authentication.
If you are using hapi-auth-jwt2 this plugin will read the token from that. In that case you'd want to be sure you are passing the same secret and necessary configuration to hapi-postgraphile, and if you're using jwt2 cookies the same security caveats as below will apply.
If you do use this approach, also remember that you likely want to allow unauthenticated calls to the graphql endpoint as well. In that case consider passing a route option to hapi-postgraphile, like:
route: {
options: {
auth: {
mode: 'optional'
}
}
}
You can also set up your endpoint to store a cookie containing your JWT.
When setting up an authentication cookie you should also review the authenticate.verifyOrigin
setting.
You must provide a cookieAuthentication.name
, which is the name of your cookie, and should review the authenticate.getTokenOperationName
, authenticate.getTokenDataPath
, and authenticate.clearTokenOperationName
options to ensure your queries and responses are handled. The default settings
mirror the results you'd have following this tutorial.
Using the default settings should give you a reasonable level of security against CSRF attacks. These settings rely solely on the Authorization
header, and should be immune to the most common exploits. Cookies are very convenient in some settings, but come with an added security risk, especially given the level of access a GraphQL endpoint typically has to the underlying database.
If you do choose to use cookie authentication you can use the authentication.verifyOrigin
checking to ensure that your request is coming from an allowed origin based on your server's CORS policy. The plugin will check hapi's request.info.cors.isOriginMatch
to ensure you have a valid origin. This can happen either on every request, always
, or just on requests that contain the origin header — the present
setting, which is a sensible default.
For a secure setup with cookies you must do the following
-
Ensure your route has a secure CORS policy in place either at the server level or through a route option you pass to this plugin. Read about setting your server CORS policy and / or your route CORS policy. Setting
cors: true
orcors: ['*']
is not secure! -
Set the
hapi-postgraphile
config optionauthentication.verifyOrigin
toalways
orpresent
. If you do not update this value and you enable cookies the value will be upgraded topresent
for you and a warning will be thrown. -
Ensure your cookie is using the
isSecure
andhttpOnly
options (both defaults) to prevent against manipulation and domain forgery. -
Consider also using anti-CSRF tokens like those provided by crumb.
Read the CSRF Prevention Cheat Sheet for more detail.
If you do use cookie authentication, I've included token refresh functionality.
At a basic level this would allow you to create and call a refreshToken
mutation, which is expected to read from the jwt_claims
and return a jwtToken
very similar to the reference authenticate
mutation. In your PG function you
might simply verify that the claimed identity still exists and is allowed, or
you might check a session table to ensure they are still allowed access.
For example:
create or replace function forum_example.refresh_token() returns forum_example.jwt_token as $$
declare
account forum_example_private.person_account;
begin
select a.* into account
from forum_example_private.person_account as a
where a.person_id = current_setting('jwt.claims.person_id')::text;
if FOUND and (account.suspended <> true) then
return ('forum_example_user', account.person_id)::forum_example.jwt_token;
else
return null;
end if;
end;
$$ language plpgsql strict security definer;
grant execute on function forum_example.refresh_token() to forum_example_user;
By defining the authenticate.refreshTokenOperationName
and
authenticate.refreshTokenDataPath
you can have your new token re-stated.
If you return a jwtToken with a sat
("stale at") property this plugin will
compare that value with the current time and refresh if necessary. sat
, like
other JWT properties, should be a UNIX epoch time in seconds.
This approach assumes you have the decoded token available in your
request.auth.credentials
object — like the one provided by hapi-auth-jwt2
.
You could also create your own auth strategy to decode the token and populate
this value, but be aware that postgraphile
itself does not expose the
decoded token itself.
The refresh will happen during the onPreResponse
extension point. You will
need to supply an authenticate.refreshTokenQuery
GraphQL query string, which
will be invoked when the stale conditions are met.
The following is an example of an authentication PG type and function that provides a valid JWT that could be refreshed sometime after it becomes stale and before it expires:
create type forum_example.jwt_token as (
role text,
person_id text,
exp int,
sat int
);
create or replace function forum_example.authenticate(
email text,
password text
) returns forum_example.jwt_token as $$
declare
account forum_example_private.person_account;
epoch_time int;
expires_in int default 1800;
stale_in int default 900;
begin
select a.* into account
from forum_example_private.person_account as a
where a.email = $1;
if (account.suspended <> true) and (account.password_hash = crypt(password, account.password_hash)) then
epoch_time := extract(epoch from now());
-- 30 minute expiration, 15 minutes until stale
return ('forum_example_user', account.person_id, epoch_time + expires_in, epoch_time + stale_in)::forum_example.jwt_token;
else
raise exception 'invalid login';
end if;
end;
$$ language plpgsql strict security definer;
Don't set your stale time too close to your expiration time to avoid issues.
Defaults shown.
{
pgConfig: '', // connection string or obj
pgOptions: null, // object to merge with config, for pg tuning, etc
pgConnectionRetry: { // Settings for the retry module, invoked on connection errors.
retries: 5, // Set to 0 to disable
factor: 2,
minTimeout: 1000,
maxTimeout: 100000,
random: false
},
schemaName: 'public',
schemaOptions: {
// options from postgraphile
},
route: {
path: '/graphql',
options: null // options to pass to your route handler, merged with (and some overwritten by) the plugin's route options
},
cacheAllowedOperations: null, // pass array of strings
cacheConfig: { // null by default
segment: '',
expiresIn: 0,
expiresAt: '',
staleIn: 0,
staleTimeout: 0,
generateTimeout: 500
},
authenticate: {
verifyOrigin: 'never', // or 'always' or 'present'
verifyOriginOverride: false, // By default origin will be verified if using cookie auth. This let's you keep it as 'never'.
getTokenOperationName: 'getToken', // your login or operation mutation
getTokenDataPath: 'data.getToken.jwtToken',
refreshTokenOperationName: 'refreshToken', // if you choose to use the refreshToken functionality
refreshTokenDataPath: 'data.refreshToken.jwtToken',
refreshTokenQuery: undefined, // if you want to use the refreshToken functionality, put your graphql mutation string here
refreshTokenVariables: undefined, // if your query requires any variables, object here
clearTokenOperationName: 'clearToken'
},
headerAuthentication: {
headerName: 'Authorization',
tokenType: 'Bearer'
},
cookieAuthentication: { // by default this is null, to use cookies pass a name and any hapi cookie options — default options shown
name: null,
options: {
encoding: 'none',
isSecure: true,
isHttpOnly: true,
clearInvalid: false,
strictHeader: true,
path: '/'
}
}
}
Check out the /examples
folder for a comprehensive implementation.
hapi-postgraphile
will use the native pg
bindings if you have pg-native
installed as a peer.
-
postgraphile.performQuery(graphqlQuery, [options])
graphqlQuery
:{query, variables, operationName}
options
:{jwtToken, [schemaOptions]}
— the options object can provide the JWT for the request and override any of the global schemaOptions if needed.
-
postgraphile.performQueryWithCache(graphqlQuery)
graphqlQuery
:{query, variables, operationName}
- cached queries cannot use options — they are ultimately uncacheable with a simple key/val lookup, and we'd also run into issues with JWT authentication.