relay-tools / react-relay-network-layer

ReactRelayNetworkLayer with middlewares and query batching for Relay Classic.
MIT License
277 stars 47 forks source link

How do you use authMiddleware? #6

Open sibelius opened 8 years ago

sibelius commented 8 years ago

I want to set the auth token after login/register process using the token received

is it possible? do u have an example?

nodkz commented 8 years ago

Just provide auth middleware to network layer with a token thunk. This function will be called every time, when Relay makes request. You may store your token in global var, localStorage. In my project I use https://www.npmjs.com/package/store

Relay.injectNetworkLayer(new RelayNetworkLayer([
  authMiddleware({
    token: () => window.MyGlobalVarWithToken, // store.get('jwt'), 
  }),
]));
sibelius commented 8 years ago

thanks, I will use AsyncStorage.

calling it every time is not a problem?

nodkz commented 8 years ago

I don't sure that token: async () => await storage('token'), will work at all. But if does, then you'll get delays before relay send queries to server.

So if possible, try to put token in some variable with sync access.

BTW don't forget about tokenRefreshPromise option in authMiddleware. This promise will be called only if your server returns 401 response. After resolving of token, it implicitly make re-request.

At the end, you may write your own middleware for working with your token logic. As example you may see this simple MW: https://github.com/nodkz/react-relay-network-layer/blob/master/src/middleware/perf.js You may change req.headers = {} before call next(req).

sibelius commented 8 years ago

thanks for all the help, I will try to write a middleware

nodkz commented 8 years ago

Few minutes ago I publish new version, where was added allowEmptyToken options to authMiddleware. If allowEmptyToken: true, and token is empty, request proceed without Authorization header.

This can help you, if you do requests to graphql server without token and don't want send empty auth header.

sibelius commented 8 years ago

it could be very useful if we could just call a function on authMiddleware to save the token or remove it

nodkz commented 8 years ago

Assumption: Suppose, such function exists. So you should somehow save reference to this function in some global variable. When you log in, you take this var with function and call it. So I can not imagine this somehow realization should be implemented.

Current realization: Let this variable save not reference to function which you wish, let it keep token. So just provide this variable to arrow function for token option. When you log in, you just write token to this global var. And with next Relay request it reads token from this global var.

I try keep MW realization as simple, as possible. If I begin store token internally, I should also provide functions for reading and removing token ;).

sibelius commented 8 years ago

I think it is better to use Relay.Environment to reset relay data on login, thanks for all the support

nodkz commented 8 years ago

How I actually use auth middleware:

class ClientApi {
  ...
  getRelayNetworkLayer = () => { 
    return new RelayNetworkLayer([
      authMiddleware({
        token: () => this._token,
        tokenRefreshPromise: () => this._getTokenFromServer(this.url('/auth/jwt/new')),
      }),
  ...
}

refresh token promise has the following view

  _getTokenFromServer = (url, data = {}) => {
    console.log('[AUTH] LOAD NEW CABINET JWT');
    const opts = {
      method: 'POST',
      headers: {
        'Accept': 'application/json', // eslint-disable-line
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(data),
    };
    return fetch(url, opts)
      .then(res => {
        if (res.status > 299) { throw new Error(`Wrong response status ${res.status}`); }
        return res;
      })
      .then(res => res.json())
      .then(json => {
        this._token = json.token;
        this._saveTokenToStorage();
        return this._token;
      })
      .catch(err => console.error('[AUTH] ERROR can not load new cabinet jwt', err));
  };

So if new user makes request to server, it responds with 401 header. AuthMW implicitly catchs this header and run _getTokenFromServer. Internally it makes new request to obtain new token from server, and if succeed, store it to localStorage and pass new token to AuthMW. AuthMW makes re-request and seamlessly pass data to Relay.

So app logic does not know anything about authorization.

Our ClientApi also contains following methods loginByOauth (providerName), loginByEmail(...), logout(). And instance of ClientApi stored in global variable.

Our ClientApi class was written inspired by https://github.com/reindexio/reindex-js/blob/master/src/index.js

sibelius commented 8 years ago

for every 401 it will try to get the token?

how _getTokenFromServer get data parameter?

the problem with this approach is that you do not clean the Relay cache, so if an user logged in, then logout, and another user login, this second user could have access to some left data from the first user

nodkz commented 8 years ago

Yep, for every 401 request it tries to refresh token calling _getTokenFromServer, and made re-request. If second request produce again 401 error it will be thrown upper. FYI you may open dialog window with login and password, send it server, get token, and after that resolve Promise from _getTokenFromServer.

data parameter is undefined for this case. I use method _getTokenFromServer from another methods like this ClientApi.switchUser(), loginByEmail(), logout(), loginByOauth(). This methods provide some data for auth server for generating proper token.

In my case I just reload page after logout(). For switchUser() after obtaining token, I just force reload some counters after call, in my case no need clear entire relay store.

sibelius commented 8 years ago

reload a page is not a good user experience, and it does not work on react native either

allpwrfulroot commented 8 years ago

Also trying to solve this issue on React Native. Did anyone successfully implement a solution for pre-register -> post-login Relay?

allpwrfulroot commented 8 years ago

Happy to keep this (work in progress) React Native sample public as a template, but how to fix the middleware implementation? https://github.com/allpwrfulroot/testMiddleware

Update: condensed the code question to a gist https://gist.github.com/allpwrfulroot/6b6b58ee2a3efebf138f00714c63630b

chris-verclytte commented 8 years ago

@sibelius, to address the problem avoiding page refresh, the solution provided by Relay is to create a new Store (see RelayEnvironment) which clean all the cache and avoid one user to see data it should not. react-router-relay and Relay.Renderer allow you to provide this environment prop.

sibelius commented 8 years ago

This could be useful to easily reset a RelayEnvironment

class RelayStore {
  constructor() {
    this._env = new Environment();
    this._networkLayer = null;
    this._taskScheduler = null;

    RelayNetworkDebug.init(this._env);
  }

  reset(networkLayer) {
    // invariant(
    //   !this._env.getStoreData().getChangeEmitter().hasActiveListeners() &&
    //   !this._env.getStoreData().getMutationQueue().hasPendingMutations() &&
    //   !this._env.getStoreData().getPendingQueryTracker().hasPendingQueries(),
    //   'RelayStore.reset(): Cannot reset the store while there are active ' +
    //   'Relay Containers or pending mutations/queries.'
    // );

    if (networkLayer !== undefined) {
      this._networkLayer = networkLayer;
    }

    this._env = new Environment();
    if (this._networkLayer !== null) {
      this._env.injectNetworkLayer(this._networkLayer);
    }
    if (this._taskScheduler !== null) {
      this._env.injectTaskScheduler(this._taskScheduler);
    }

    RelayNetworkDebug.init(this._env);
  }

  // Map existing RelayEnvironment methods
  getStoreData() {
    return this._env.getStoreData();
  }

  injectNetworkLayer(networkLayer) {
    this._networkLayer = networkLayer;
    this._env.injectNetworkLayer(networkLayer);
  }

  injectTaskScheduler(taskScheduler) {
    this._taskScheduler = taskScheduler;
    this._env.injectTaskScheduler(taskScheduler);
  }

  primeCache(...args) {
    return this._env.primeCache(...args);
  }

  forceFetch(...args) {
    return this._env.forceFetch(...args);
  }

  read(...args) {
    return this._env.read(...args);
  }

  readAll(...args) {
    return this._env.readAll(...args);
  }

  readQuery(...args) {
    return this._env.readQuery(...args);
  }

  observe(...args) {
    return this._env.observe(...args);
  }

  getFragmentResolver(...args) {
    return this._env.getFragmentResolver(...args);
  }

  applyUpdate(...args) {
    return this._env.applyUpdate(...args);
  }

  commitUpdate(...args) {
    return this._env.commitUpdate(...args);
  }

  /**
   * @deprecated
   *
   * Method renamed to commitUpdate
   */
  update(...args) {
    return this._env.update(...args);
  }
}

const relayStore = new RelayStore();

just call: RelayStore.reset(networkLayer)

chris-verclytte commented 8 years ago

I prefer to solve the problem by simply storing the instance of the environment in a singleton and provide a method to refresh the environment as it avoids a lot of code duplication. Here is my way to deal with this:

import Relay from 'react-relay';
import {
  RelayNetworkLayer,
  authMiddleware,
  urlMiddleware,
} from 'react-relay-network-layer';

let instance = null;

const tokenRefreshPromise = () => ...;

const refresh = () => {
  instance = new Relay.Environment();

  instance.injectNetworkLayer(new RelayNetworkLayer([
    urlMiddleware({
      url: '<some_url>',
      batchUrl: '<some_batch_url>',
    }),

    next => req => next(
      // Retrieve token info and assign it to req
    ),
    authMiddleware({
      allowEmptyToken: true,
      token: req => req.token,
      tokenRefreshPromise,
    }),
  ], { disableBatchQuery: true }));

  return instance;
};

const getInstance = () => (instance || refresh());

export default {
  getCurrent: getInstance.bind(this),
  refresh: refresh.bind(this),
};

Then you can import this file as Store and pass it to your RelayRenderer as Store.getInstance() and when you need to refresh your data after a logout for instance, just include Store in your file and call Store.refresh(), it generates a new instance and updates it everywhere.

ryanblakeley commented 7 years ago

This is how I'm doing it:

networkLayer.js

const networkLayer = new RelayNetworkLayer([
  urlMiddleware({
    url: _ => '/graphql',
  }),
  authMiddleware({
    token: () => localStorage.getItem('id_token'),
  }),
], {disableBatchQuery: true});

Root.js

import React from 'react';
import Relay from 'react-relay';
import {
  Router,
  browserHistory,
  applyRouterMiddleware,
} from 'react-router';
import useRelay from 'react-router-relay';
import ReactGA from 'react-ga';
import networkLayer from 'shared/utils/networkLayer';
import routes from './routes';

class Root extends React.Component {
  static childContextTypes = {
    logout: React.PropTypes.func,
  };
  state = {
    environment: null,
  };
  getChildContext () {
    return {
      logout: _ => this.logout(),
    };
  }
  componentWillMount () {
    ReactGA.initialize(process.env.GOOGLE_ANALYTICS_KEY);
    const environment = new Relay.Environment();
    environment.injectNetworkLayer(networkLayer);
    this.setState({environment});
  }
  logout () {
    const environment = new Relay.Environment();
    environment.injectNetworkLayer(networkLayer);
    this.setState({environment});
  }
  logPageView = _ => {
    ReactGA.set({ page: window.location.pathname });
    ReactGA.pageview(window.location.pathname);
  }
  render () {
    return <Router
      render={applyRouterMiddleware(useRelay)}
      history={browserHistory}
      environment={this.state.environment}
      routes={routes}
      onUpdate={this.logPageView}
      key={Math.random()}
    />;
  }
}

LoginPage.js

This page has a method that gets passed to a login form. The login form calls an AuthenticateUserMutation, and uses this function as a callback:

  loginUser = data => {
    localStorage.setItem('id_token', data.jwtToken);
    localStorage.setItem('user_uuid', data.userId);
    this.context.setLoggedIn(true);
    this.context.setUserId(data.userId);
    this.context.router.push('/profile');
  }

LoginForm.js

  processLogin = response => {
    const { authenticateUser: { authenticateUserResult } } = response;

    if (authenticateUserResult && authenticateUserResult.userId) {
      this.props.loginUser(authenticateUserResult);
    } else {
      this.setState({ loginError: 'Email and/or Password is incorrect' });
    }
  }
  loginUser = ({ email, password }) => {
    this.props.relay.commitUpdate(
      new AuthenticateUserMutation({ email, password }),
      { onSuccess: this.processLogin },
    );
  }
koistya commented 1 year ago

core/relay.ts

import { getAuth } from "firebase/auth";
import { Environment, Network, RecordSource, Store } from "relay-runtime";

/**
 * Initializes a new instance of Relay environment.
 * @see https://relay.dev/docs/
 */
export function createRelay(): Environment {
  // Configure a network layer that fetches data from the GraphQL API
  // https://relay.dev/docs/guides/network-layer/
  const network = Network.create(async function fetchFn(operation, variables) {
    const auth = getAuth();
    const headers = new Headers({ ["Content-Type"]: "application/json" });

    // When the user is authenticated append the ID token to the request
    if (auth.currentUser) {
      const idToken = await auth.currentUser.getIdToken();
      headers.set("Authorization", `Bearer ${idToken}`);
    }

    const res = await fetch("/api", {
      method: "POST",
      headers,
      credentials: "include",
      body: JSON.stringify({ query: operation.text, variables }),
    });

    if (!res.ok) {
      throw new HttpError(res.status, res.statusText);
    }

    return await res.json();
  });

  // Initialize Relay records store
  const recordSource = new RecordSource();
  const store = new Store(recordSource);

  return new Environment({ store, network, handlerProvider: null });
}

Then, somewhere at the root level:

import { useMemo } as React from "react";
import { RelayEnvironmentProvider } from "react-relay";
import { createRelay } from "../core/relay.js";
import { AppRoutes } from "../routes/index.js";

/**
 * The top-level (root) React component.
 */
export function App(): JSX.Element {
  const relay = useMemo(() => createRelay(), []);

  return (
    <RelayEnvironmentProvider environment={relay}>
      <AppRoutes />
    </RelayEnvironmentProvider>
  );
}

https://github.com/kriasoft/relay-starter-kit