'Capacitor iOS Using Cookie Based Auth

I am using Capacitor v3, NextJS static export, and a Django backend to build out an iOS app based on a production website.

The current backend authentication scheme uses Django sessions via cookies as well as setting the CSRF token via cookies. The CSRF token can be bypassed pretty easily for the app and not worried about disabling that but forking our authentication scheme would be somewhat of a hassle. The capacitor-community/http claims to allow Cookies but I haven't been able to configure that correctly.

Capacitor Config:

import { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
  appId: 'com.nextwebapp.app',
  appName: 'nextwebapp',
  webDir: 'out',
  bundledWebRuntime: false
};

export default config;

Note that I have tried setting server.hostname to myapp.com as well.

Based on the comments at the bottom of the capacitor http readme I set the following Info.plist values.

App/Info.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
....
    <key>WKAppBoundDomains</key>
    <array>
        <string>staging.myapp.com</string>
        <string>myapp.com</string>
    </array>
</dict>
</plist>

The web app uses a react hooks wrapper package for axios so in order to keep changes minimal I made a hook that mimics the state returned from that package.

hooks/useNativeRequest.ts

import { useEffect, useState } from "react";

import { Http } from "@capacitor-community/http";

import {
  BASE_URL,
  DEFAULT_HEADERS,
  HOST_NAME,
  ERROR_MESSAGE,
  Refetch,
  RequestOptions,
  ResponseValues,
  RequestConfig,
} from "@utils/http";
import { handleResponseToast } from "@utils/toast";

const makeUrl = (url): string => `${BASE_URL}${url}`;

const getCSRFToken = async () =>
  await Http.getCookie({ key: "csrftoken", url: HOST_NAME });

const combineHeaders = async (headers: any) => {
  const newHeaders = Object.assign(DEFAULT_HEADERS, headers);
  const csrfHeader = await getCSRFToken();

  if (csrfHeader.value) {
    newHeaders["X-CSRFToken"] = csrfHeader.value;
  }

  return newHeaders;
};

function useNativeRequest<T>(
  config?: RequestConfig,
  options?: RequestOptions
): [ResponseValues<T>, Refetch<T>] {
  const [responseState, setResponseState] = useState({
    data: null,
    error: null,
    loading: false,
  });

  let method = "get";
  let url = config;
  let headers = {};
  let params = undefined;
  let data = undefined;
  if (config && typeof config !== "string") {
    url = config.url;
    method = config.method?.toLowerCase() ?? method;
    headers = config.headers;
    params = config.params;
    data = config.data;
  }

  const requestMethod = Http[method];

  const makeRequest = async () => {
    setResponseState({ error: null, data: null, loading: true });

    try {
      const reqHeaders = await combineHeaders(headers);
      console.log({
        url,
        reqHeaders,
        params,
        data
      })
      const response = await requestMethod({
        url: makeUrl(url),
        headers: reqHeaders,
        params,
        data,
      });

      if (response?.status === 200) {
        setResponseState({ error: null, data: response.data, loading: false });
        handleResponseToast(response?.data?.detail);
      } else {
        const errorMessage = response?.data?.detail || ERROR_MESSAGE;
        handleResponseToast(errorMessage);
        setResponseState({
          error: errorMessage,
          data: response.data,
          loading: false,
        });
      }

      return response;
    } catch {
      setResponseState({
        error: ERROR_MESSAGE,
        data: null,
        loading: false,
      });

      return Promise.reject(ERROR_MESSAGE);
    }
  };

  useEffect(() => {
    if (!options?.manual) {
      makeRequest();
    }
  }, [options?.manual]);

  return [responseState, makeRequest];
}

export { useNativeRequest };

The console.log above never includes the additional csrf cookie and in the getter logs it doesn't contain a value.

Backend Django

    MIDDLEWARE = [
      ...
      'myapp_webapp.middle.CustomCSRFMiddleWare',
    ]
    
    CORS_ALLOWED_ORIGINS = [
        ...
        "capacitor://localhost",
    ]

   REST_FRAMEWORK = {
      'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework.authentication.BasicAuthentication',
        'rest_framework.authentication.TokenAuthentication',
        'rest_framework.authentication.SessionAuthentication',
      ),
    }

middleware

class CustomCSRFMiddleWare(CsrfViewMiddleware):

    def process_request(self, request):
        # Special Processing for API Requests
        if "/api/v1" in request.path:
            try:
                requested_with = request.headers['X-Requested-With']
                myapp_request = request.headers['X-Myapp-Request']

                # Check Custom Headers
                if not (requested_with == 'XMLHttpRequest' and myapp_request == '1'):
                    raise PermissionDenied()

                return None

            except KeyError:
                # All API Requests should include the above headers
                raise PermissionDenied()

        
        # Call original CSRF Middleware
        return super(CustomCSRFMiddleWare, self).process_request(request)

Occasionally the backend will also show that X-Requested-With is not being sent but it is included in the DEFAULT_HEADERS constant I have in the UI and appears in the console.log.

Is anything above preventing me from being able to read and send cookies from Capacitor on iOS? Does Cookie based authentication even work with capacitor?



Solution 1:[1]

Here is my updated react hook that combine's my above question and thread mentioned in the comments as well as some manual cookie setting.

The below client side code worked without changes to existing Django Session authentication.

The changes from my code above

  • Added credentials: "include" to webFetchExtra
  • Added "Content-Type": "application/json" to headers
  • Handle override of the initial config for manual request & refetch
  • Set Session Cookie After Response
  • Based on the docs this shouldn't be necessary but I am keeping in my code for now.
import { useCallback, useEffect, useState } from "react";

import { AxiosRequestConfig } from "axios";
import { Http } from "@capacitor-community/http";

const DEFAULT_HEADERS = {
  "X-Requested-With": "XMLHttpRequest",
  "X-MyApp-Request": "1",
  "Content-Type": "application/json",
};

const makeUrl = (url): string => `${BASE_URL}${url}`;

const getCSRFToken = async () =>
  await Http.getCookie({ key: "csrftoken", url: HOST_NAME });

const setSessionCookie = async () => {
  const sessionId = await Http.getCookie({ key: "sessionid", url: HOST_NAME });

  if (sessionId.value) {
    await Http.setCookie({
      key: "sessionid",
      value: sessionId.value,
      url: HOST_NAME,
    });
  }
};

const combineHeaders = async (headers: any) => {
  const newHeaders = Object.assign(DEFAULT_HEADERS, headers);
  const csrfHeader = await getCSRFToken();

  if (csrfHeader.value) {
    newHeaders["X-CSRFToken"] = csrfHeader.value;
  }

  return newHeaders;
};

const parseConfig = (config: RequestConfig, configOverride?: RequestConfig) => {
  let method = "get";
  let url = config;
  let headers = {};
  let params = undefined;
  let data = undefined;

  if (config && typeof config !== "string") {
    url = config.url;
    method = config.method ?? method;
    headers = config.headers;
    params = config.params;
    data = config.data;
  }

  return {
    url,
    method,
    headers,
    params,
    data,
    ...(configOverride as AxiosRequestConfig),
  };
};

function useNativeRequest<T>(
  config?: RequestConfig,
  options?: RequestOptions
): [ResponseValues<T>, Refetch<T>] {
  const [responseState, setResponseState] = useState({
    data: null,
    error: null,
    loading: false,
  });

  const makeRequest = useCallback(
    async (configOverride) => {
      setResponseState({ error: null, data: null, loading: true });
      const { url, method, headers, params, data } = parseConfig(
        config,
        configOverride
      );

      try {
        const reqHeaders = await combineHeaders(headers);
        const response = await Http.request({
          url: makeUrl(url),
          headers: reqHeaders,
          method,
          params,
          data,
          webFetchExtra: {
            credentials: "include",
          },
        });

        if (response?.status === 200) {
          setResponseState({
            error: null,
            data: response.data,
            loading: false,
          });
          await setSessionCookie();
        } else {
          setResponseState({
            error: errorMessage,
            data: response.data,
            loading: false,
          });
        }

        return response;
      } catch {
        setResponseState({
          error: ERROR_MESSAGE,
          data: null,
          loading: false,
        });

        return Promise.reject(ERROR_MESSAGE);
      }
    },
    [config]
  );

  useEffect(() => {
    if (!options?.manual) {
      makeRequest(config);
    }
  }, [options?.manual]);

  return [responseState, makeRequest];
}

export { useNativeRequest };

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 BillPull