Search code examples
postgresqlgraphqlreact-apolloapollo-clientpostgraphile

"[GraphQL error]: Message: permission denied for function" using JWT in headers


The problem

Queries not requiring authorisation succeed, but a query requiring JWT authorisation fails.

Errors

In the browser console, I get the following error message:

[GraphQL error]: Message: permission denied for function get_account_info, Location: [object Object], Path: getAccountInfo

And this is the error I get in the server console:

1 error(s) as guest in 101.18ms :: { getAccountInfo { username interface native customNative tutorial email __typename } }

The fact that the error says as guest means that the role hasn't been set correctly (otherwise it would say as loggedin). I'm fairly sure this error isn't due to something on the SQL side, but rather in my JS code, but I've provided some SQL code below just in case.

The request

I installed GraphQL Developer Tools and saw that this is what was being sent in the request:

Request

  • Request URL: http://localhost:3000/graphql
  • Method: POST
  • HTTP version: HTTP/1.1
  • Headers:
    • Origin: http://localhost:3000
    • Accept-Encoding: gzip, deflate, br
    • Host: localhost:3000
    • Accept-Language: pl-PL,pl;q=0.9,en-US;q=0.8,en;q=0.7,fr;q=0.6,lt;q=0.5,es;q=0.4
    • User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36
    • content-type: application/json
    • accept: /
    • Referer: http://localhost:3000/login
    • Cookie: authorization=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjaWQiOjgsInN1YiI6InN0YXNAbXJzd29yZHNtaXRoLmNvbSIsImlzcyI6Imh0dHA6Ly9td3MtbWxhLmNvbSIsInBlcm1pc3Npb25zIjoxLCJpYXQiOjE1MjIwNzA4NzYsImV4cCI6MTUyMjY3NTY3Nn0.cXoy-SxSc5YVJ36lSmUoKAYU8KpZsZaFOS-xqcmbKPg
    • Connection: keep-alive
    • Content-Length: 179
    • DNT: 1

Note that Cookie has authorization=[some token]. Does this mean that there is no authorization header because it is living under Cookie for some reason? If so, how do I set the header correctly? Or is there anything else that I'm doing wrong?

The SQL code

I'm pretty sure that the SQL is fine, but here it is just in case.

JWT generation

CREATE FUNCTION private.generate_jwt_for_user(username text)
    RETURNS json_web_token
    LANGUAGE plpgsql
    STABLE
    AS $$
        DECLARE
            n_moderator bigint;
        BEGIN
            SELECT count(*) INTO n_moderator
               FROM private.moderator
               WHERE account = username;

            IF n_moderator = 0
            THEN
                RETURN ('loggedin', username)::json_web_token; -- x::Y means cast x to type Y
            ELSE
                RETURN ('moderator', username)::json_web_token;
            END IF;
        END;
    $$;

get_account_info

CREATE FUNCTION public.get_account_info()
    RETURNS private.account_info
    LANGUAGE SQL
    SECURITY DEFINER
    STABLE
    AS $$
        SELECT *
        FROM private.account_info
        WHERE username = current_setting('jwt.claims.username')
    $$;

The JavaScript code

main.js

// Meteor startup script. Runs reactRoutes, and puts the result in the 'content' div in index.html.

import { Meteor } from 'meteor/meteor'
import { render } from 'react-dom'
import Routes from './routes'
import React from 'react'
import ApolloClient from 'apollo-boost'
import { HttpLink } from 'apollo-link-http'
import { ApolloLink, from } from 'apollo-link'
import { ApolloProvider } from 'react-apollo'

// Connect to the database using Apollo
// Add middleware that adds a Json Web Token (JWT) to the request header

const httpLink = new HttpLink({ uri: '/graphql' });

const authMiddleware = new ApolloLink((operation, forward) => {
  // add the authorization to the headers
  const token = localStorage.getItem('token')
  operation.setContext(({ headers = {} }) => ({
    headers: {
      ...headers,
      authorization: 'Bearer ' + token || null,
    } 
  }));

  return forward(operation);
})

const client = new ApolloClient({
  link: from([
    authMiddleware,
    httpLink
  ]),
});

// <ApolloProvider> allows React to connect to Apollo
// <Routes> allows client-side routing
// The rendered page inserted into the HTML under 'content'
Meteor.startup(() => {
    render(
        <ApolloProvider client={client}>
            <Routes/>
        </ApolloProvider>,
        document.getElementById('content'))
})

app.js

Apologies for the long code with the occasional TODO comment, this is still a work in progress.

import React from 'react'
import jwtDecode from 'jwt-decode'
import { withApollo, graphql } from 'react-apollo'
import gql from 'graphql-tag'
import Nav from './auxiliary/nav'
import Translate from 'react-translate-component'

class UserAppBody extends React.Component {
    constructor(props) {
        super(props)
        this.state = {
            activeLanguageId: null
        }
    }

    setLanguage(langId) {
        this.setState({
            activeLanguageId: langId
        })
    }

    render() {
        let native = null
        let username = false
        // TODO: remove all userId references in app
        let tutorial = false
        if (this.props.accountInfo) {
            console.log("jwt: " + localStorage['token'])
            if (this.props.accountInfo.loading) { return <Translate component="div" content="loading.loading" /> }
            console.log(this.props.accountInfo)
            username = this.props.accountInfo.getAccountInfo.username
            tutorial = this.props.accountInfo.getAccountInfo.tutorial
            native =   this.props.accountInfo.getAccountInfo.native
        }

        return (
            <div id="app-container">
                <Nav callbackLogOut={this.props.logOut} username={username} />
                {/* Insert the children according to routes.jsx (this.props.children), along with the childrens' props.
                username should come from query due to being wrapped by graphql for wrapped case; otherwise username is bool: false. */}
                {React.cloneElement(
                    this.props.children, 
                    {
                        username: username,
                        hasSeenTutorial: tutorial,
                        native: native,
                        activeLanguageId: this.state.activeLanguageId, 
                        callbackLanguage: this.setLanguage.bind(this),
                        callbackUser: this.props.setUser,
                        callbackLogOut: this.props.logOut
                    }
                )}
            </div>
        )
    }
}

// UserAppBody will be wrapped in AppBody if user is logged in, this setup comes before the wrapping
// Calling graphql on this turns it into a function which returns a React element (needed below)
const accountInfoQuery = gql`query{
    getAccountInfo {
        username
        interface
        native
        customNative
        tutorial
        email
    }
}`

const accountInfoQueryConfig = {
    name: 'accountInfo'
}
const SignedInAppBody = graphql(accountInfoQuery, accountInfoQueryConfig)(UserAppBody)


class AppBody extends React.Component {
    constructor(props) {
        super(props)
        const raw_jwt = localStorage.getItem('token')
        this.state = {
            isLoggedIn: !!raw_jwt // true if there is a jwt in local storage, false otherwise
        }
    }

    setUser(raw_jwt) {
        const jwt = jwtDecode(raw_jwt)

        // Check if the token has expired
        // Note that getTime() is in milliseconds, but jwt.exp is in seconds
        const timestamp = (new Date).getTime()
        if (!!jwt && timestamp < jwt.exp * 1000) {
            // If the token is still valid:
            // Store the token in memory, to be added to request headers
            localStorage.setItem('token', raw_jwt)
            // Set the state, to change the app
            this.setState({
                isLoggedIn: true
            })
            // Automatically refresh the token
            this.refreshTimer = setInterval(this.refresh, 1000*60*20)  // Refresh every 20 minutes
            console.log('timer set up')

        } else {
            // If the token is no longer valid, log out to clear information
            this.logOut()
        }
    }

    logOut() {
        // Clear everything from setUser (state, memory, refreshing)
        localStorage.removeItem('refreshToken')
        localStorage.removeItem('token')
        clearInterval(this.refreshTimer)
        console.log('logging out')
        // second argument is a callback that setState will call when it is finished
        this.setState( { isLoggedIn: false }, this.props.client.resetStore() )
    }

    refresh() {
        // Get a new token using the refresh code
        this.props.refresh({variables: {input: {refreshToken: localStorage.getItem('refreshToken')}}})
        .then((response) => {
            // Store the new token
            const raw_jwt = response.data.refresh.jsonWebToken
            localStorage.setItem('token', raw_jwt)
        }).catch((error) => {
            // If we can't connect to the server, try again
            if (error.networkError) {
                console.log('network error?') //TODO
                //this.refresh()
            } else { //TODO
                // If we connected to the server and refreshing failed, log out
                console.log('error, logging out')
                console.log(error)
                this.logOut()
            }
        })
    }

    componentWillMount() {
        const raw_jwt = localStorage.getItem('token')
        if (!!raw_jwt) {
            console.log('found json web token, running setUser as App compenent mounts')
            this.setUser(raw_jwt)
            this.refresh()
        }
    }

    render() {
        let AppBodyClass

        if (this.state.isLoggedIn) {
            AppBodyClass = SignedInAppBody
        } else {
            AppBodyClass = UserAppBody
        }
        return <AppBodyClass children={this.props.children} setUser={this.setUser.bind(this)} logOut={this.logOut.bind(this)} />
    }
}

const refresh = gql`mutation($input:RefreshInput!) {
    refresh(input:$input) {
        jsonWebToken
    }
}`
const refreshConfig = {
    name: 'refresh'
}

export default withApollo(graphql(refresh, refreshConfig)(AppBody))

Solution

  • @Benjie is right that the middleware isn't working and so the header isn't being added. The problem is that apollo-boost doesn't allow a link option. ApolloClient should be imported from apollo-client instead.