Stas
Stas

Reputation: 345

"[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

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))

Upvotes: 3

Views: 3725

Answers (2)

eaglebrain
eaglebrain

Reputation: 356

@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.

Upvotes: 1

Benjie
Benjie

Reputation: 7946

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?

That is bizarre, but your client code appears to be correct; try using a different development tool to see what's actually being sent. The word "Bearer" has been dropped too, very odd.

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.

Putting the JWT token into the jwt.io tool, I can see that the body of the token is:

{
  "cid": 8,
  "sub": "s[AN EMAIL ADDRESS]m",
  "iss": "http://mws-mla.com",
  "permissions": 1,
  "iat": 1522070876,
  "exp": 1522675676
}

This lacks a 'role' claim, so PostGraphile won't attempt to change roles. However this doesn't seem to line up with the JWT you're generating in PostgreSQL, so I suspect this cookie is misleading. My belief is that you're not sending the authorization header at all.

Try debugging your auth middleware:

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

  return forward(operation);
})

(Note: you were previously only keeping the headers in context, in the code above I now pass through the other properties too.)

Upvotes: 2

Related Questions