Using farmOS 2.x as a OAuth2 Authorization Server

As one of the maintainers of our small community seed bank, one of my roles is running farmOS and a number of internally-facing services.

These services include GitLab which we use to host source code for our internal apps, manage issues for data quality problems, etc. Another of these services is Grafana which we use to configure/access dashboards for monitoring various sensors and physical infrastructure.

Our collaborators can use their accounts on farmOS to sign-in to GitLab and Grafana as well because farmOS is OAuth2-enabled.

For those who are unfamiliar, OAuth2 (rfc6749) is a general high-level protocol which can be used for authorization and access to information across trust-boundaries. For the purposes of this example, the simplified version is that users who are registered on our farmOS instance can effectively sign-in to GitLab/Grafana by signing in to farmOS and authorizing GitLab/Grafana to access their account/profile information.

With farmOS 1.x, I manually configured the OAuth2 clients and their client records in farmOS such that GitLab and Grafana could authenticate users by signing in through farmOS. However, with the move to farmOS 2.x I’m trying to make as much of our infrastructure defined in code as possible.

Since this could be a common problem, I figured I should share how I’m tackling it…

It’s worth noting that this isn’t a tutorial and I’m not trying to cover all possible deployment scenarios, but I do hope this write up is helpful and informative all the same.

Background

Where possible, we try to use vanilla/official docker images and capture any customization in our docker-compose.yml file.

My actual docker-compose record for farmOS has a lot of stuff related to making the migration repeatable, but I’m going to leave that out here. (If you’re curious you can check out my migration testing logs - part one and part two.)

I’ve also removed various other bits of configuration that didn’t seem relevant, but I’ve left in a simplified version of my Nginx reverse-proxy configuration since it is slightly relevant to understanding how each of the farmos.test subdomains is being hosted in the example.

Since I’ve simplified this configuration to make it more succinct and haven’t tested the version presented here exactly as-is, it is possible that there could be minor typos or omissions that would prevent the example from working verbatim. That said, this should be an accurate representation of a configuration that is working for me right now in development and - I hope - soon in production.

Configuration

docker-compose.yml

version: '3.7'
services:

  db:
    image: postgres:12
    volumes:
      - './db2:/var/lib/postgresql/data'
    ports:
      - '5432:5432'
    environment:
      POSTGRES_USER: farm
      POSTGRES_PASSWORD: farm
      POSTGRES_DB: farm

  www:
    depends_on:
      - db
    image: farmos/farmos:2.x-dev
    entrypoint: /bin/bash
    command:
      - -c
      - |
        set -ex

        wait_db_ready() {
            while { ! exec 3<>/dev/tcp/db2/5432; } > /dev/null 2>&1; do sleep 0.1; done
        }

        if [ -d /opt/drupal ] && ! [ "$$(ls -A /opt/drupal/composer.json)" ]; then
          echo "farmOS codebase not detected. Copying from pre-built files in the Docker image."
          cp -rp /var/farmOS/. /opt/drupal
          wait_db_ready
          su www-data -s /bin/bash -c 'drush site-install farm --locale=en --db-url=pgsql://farm:farm@db2/farm --site-name=Test0 --account-name=root --account-pass=test'
        fi

        wait_db_ready

        drush --root=/opt/drupal cr

        (
        su www-data -s /bin/bash <<'EOF'
          set -ex
          #
          composer config repositories.farmos_2x_custom_modules '{"type": "path", "url": "/farmos_2x_custom_modules/*"}'
          #
          composer require symbioquine/farmos_custom_oauth @dev
          drush --root=/opt/drupal pm-enable --yes farmos_custom_oauth
        EOF
        ) || echo "failed enabling modules"

        exec docker-entrypoint.sh apache2-foreground
    volumes:
      - './www2:/opt/drupal'
      - './farmos_2x_custom_modules:/farmos_2x_custom_modules'
    expose:
      - '80'
    environment:
      #
      GRAFANA_OAUTH_REDIRECT_URL: 'https://dashboard.farmos.test/login/generic_oauth'
      GRAFANA_OAUTH_ALLOWED_ORIGINS: 'https://dashboard.farmos.test'
      GRAFANA_OAUTH_SECRET: 'some-really-secret-string-for-grafana'
      #
      GITLAB_OAUTH_REDIRECT_URL: 'https://git.farmos.test/users/auth/farmOS/callback'
      GITLAB_OAUTH_ALLOWED_ORIGINS: 'https://gitlab.farmos.test'
      GITLAB_OAUTH_SECRET: 'some-really-secret-string-for-gitlab'

  grafana:
    image: grafana/grafana
    restart: always
    expose:
      - 3000
    volumes:
      - grafana-storage:/var/lib/grafana
    environment:
      - GF_SERVER_ROOT_URL=https://dashboard.farmos.test/
      - GF_AUTH_GENERIC_OAUTH_NAME=farmOS
      - GF_AUTH_GENERIC_OAUTH_ENABLED=true
      - GF_AUTH_GENERIC_OAUTH_CLIENT_ID=grafana
      - GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET=some-really-secret-string-for-grafana
      - GF_AUTH_GENERIC_OAUTH_SCOPES=openid email profile
      - GF_AUTH_GENERIC_OAUTH_AUTH_URL=https://farmos.test/oauth/authorize
      - GF_AUTH_GENERIC_OAUTH_TOKEN_URL=https://farmos.test/oauth/token
      - GF_AUTH_GENERIC_OAUTH_API_URL=https://farmos.test/oauth/userinfo
      - GF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP=true
      - GF_AUTH_GENERIC_OAUTH_TLS_SKIP_VERIFY_INSECURE=true

  gitlab:
    image: 'gitlab/gitlab-ce:14.6.2-ce.0'
    privileged: true
    restart: always
    environment:
      GITLAB_OMNIBUS_CONFIG: |
        external_url 'https://git.farmos.test/'
        gitlab_rails['allow_single_sign_on'] = true
        gitlab_rails['omniauth_enabled'] = true
        gitlab_rails['omniauth_external_providers'] = ['farmOS']
        gitlab_rails['omniauth_allow_single_sign_on'] = ['farmOS']
        gitlab_rails['omniauth_sync_email_from_provider'] = 'farmOS'
        gitlab_rails['omniauth_block_auto_created_users'] = false
        gitlab_rails['omniauth_auto_link_ldap_user'] = false
        gitlab_rails['omniauth_sync_profile_from_provider'] = ['farmOS']
        gitlab_rails['omniauth_auto_sign_in_with_provider'] = 'farmOS'
        gitlab_rails['omniauth_sync_profile_attributes'] = ['email']
        gitlab_rails['omniauth_providers'] = [
          {
            'name' => 'oauth2_generic',
            'app_id' => 'gitlab',
            'app_secret' => 'some-really-secret-string-for-gitlab',
            'args' => {
              authorize_params: {
                   scope: 'openid email profile'
              },
              client_options: {
                'site' => 'https://farmos.test',
                'authorize_url' => '/oauth/authorize',
                'token_url' => '/oauth/token',
                'user_info_url' => '/oauth/userinfo'
              },
              user_response_structure: {
                id_path: 'sub'
              },
              name: 'farmOS',
              strategy_class: "OmniAuth::Strategies::OAuth2Generic"
            }
          }
        ]
    expose:
      - '80'
    volumes:
      - './gitlab/config:/etc/gitlab'
      - './gitlab/logs:/var/log/gitlab'
      - './gitlab/data:/var/opt/gitlab'

  proxy:
    depends_on:
      - www
      - grafana
      - gitlab
    image: nginx:latest
    user: 1337:1337
    volumes:
      - './nginx.conf:/etc/nginx/nginx.conf'
      - './devcerts:/etc/nginx/certs'
      - './nginx_error.log:/var/log/nginx/error.log'
    ports:
      - '80:8080'
      - '443:8443'

volumes:
  grafana-storage:

farmos_2x_custom_modules/farmos_custom_oauth/composer.json

{
  "name" : "symbioquine/farmos_custom_oauth",
  "description" : "Provides custom OAuth2 configuration for authenticating farmOS users.",
  "type": "drupal-module",
  "license": "GPL-3.0-or-later",
  "minimum-stability": "dev"
}

farmos_2x_custom_modules/farmos_custom_oauth/farmos_custom_oauth.info.yml

name: Custom OAuth
description: Provides custom OAuth2 configuration for authenticating farmOS users.
type: module
package: farmOS Contrib
core_version_requirement: ^9
dependencies:
  - entity:entity
  - farm_api

farmos_2x_custom_modules/farmos_custom_oauth/farmos_custom_oauth.install

<?php

/**
 * @file
 * Install, update and uninstall function for the farmos_custom_oauth module.
 */

use Drupal\consumers\Entity\Consumer;

/**
 * Implements hook_install().
 */
function farmos_custom_oauth_install() {
  add_custom_consumer([
    'client_id' => 'grafana',
    'label' => 'Grafana',
    'logo_url' => 'https://aws1.discourse-cdn.com/business7/uploads/grafana/original/2X/e/ef5308cc58c9e5a13187b5012eb99f406928462f.png',
  ]);

  add_custom_consumer([
    'client_id' => 'gitlab',
    'label' => 'GitLab',
    'logo_url' => 'https://about.gitlab.com/images/icons/logos/slp-logo.svg',
  ]);
}

function add_custom_consumer($args) {
  $uc_client_id = strtoupper($args['client_id']);

  $oauth_redirect_url = getenv("{$uc_client_id}_OAUTH_REDIRECT_URL");

  if (empty($oauth_redirect_url)) {
    throw new Exception("{$uc_client_id}_OAUTH_REDIRECT_URL env variable missing or empty");
  }

  $oauth_raw_allowed_origins = getenv("{$uc_client_id}_OAUTH_ALLOWED_ORIGINS");
  $oauth_allowed_origins = explode('|', $oauth_raw_allowed_origins ?? '');

  if (empty($oauth_raw_allowed_origins) || empty($oauth_allowed_origins)) {
    throw new Exception("{$uc_client_id}_OAUTH_ALLOWED_ORIGINS env variable missing or empty");
  }

  $oauth_secret = getenv("{$uc_client_id}_OAUTH_SECRET");

  if (empty($oauth_secret)) {
    throw new Exception("{$uc_client_id}_OAUTH_SECRET env variable missing or empty");
  }

  $logo_extension = end(explode('.', $args['logo_url']));

  $data = file_get_contents($args['logo_url']);
  $file = \Drupal::service('file.repository')->writeData($data, "public://oauth_logo_{$args['client_id']}.{$logo_extension}", \Drupal\Core\File\FileSystemInterface::EXISTS_REPLACE);

  $oauth_consumer = Consumer::create([
    'label' => $args['label'],
    'client_id' => $args['client_id'],
    'image' => ['target_id' => $file->id()],
    'redirect' => $oauth_redirect_url,
    'allowed_origins' => $oauth_allowed_origins,
    'owner_id' => '',
    'secret' => $oauth_secret,
    'confidential' => TRUE,
    'third_party' => TRUE,
    'grant_user_access' => TRUE,
    'limit_user_access' => TRUE,
    'limit_requested_access' => TRUE,
  ]);
  $oauth_consumer->save();
}

/**
 * Implements hook_uninstall().
 */
function farmos_custom_oauth_uninstall() {
  $consumer_storage = \Drupal::entityTypeManager()->getStorage('consumer');

  $result = $consumer_storage->getQuery()
    ->condition('client_id', ['grafana', 'gitlab'], 'IN')
    ->execute();

  $consumers = $result ? $consumer_storage->loadMultiple($result) : array();

  foreach ($consumers as $client_consumer) {
    $client_consumer->delete();
  }
}

farmos_2x_custom_modules/farmos_custom_oauth/farmos_custom_oauth.managed_role_permissions.yml

farmos_custom_oauth:
  default_permissions:
    - grant simple_oauth codes

Note: It’s not clear if this part of the config will be needed long term. See farmOS issue #3172315.

nginx.conf

pid        /tmp/nginx.pid;
error_log  /var/log/nginx/error.log debug;

events {

}

http {
  client_body_temp_path /tmp/client_temp;
  proxy_temp_path       /tmp/proxy_temp_path;
  fastcgi_temp_path     /tmp/fastcgi_temp;
  uwsgi_temp_path       /tmp/uwsgi_temp;
  scgi_temp_path        /tmp/scgi_temp;

  client_max_body_size 200M;

  server {
    listen 8080;
    server_name farmos.test dashboard.farmos.test git.farmos.test;

    rewrite ^/(.*)$ https://$host$request_uri? permanent; 
  }

  ssl_certificate /etc/nginx/certs/$host/fullchain.pem;
  ssl_certificate_key /etc/nginx/certs/$host/privkey.pem;

  server {
    listen 8443 ssl;
    server_name farmos.test;

    location / {
      proxy_pass http://www:80;

      proxy_set_header Host $host;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header X-Forwarded-Host $host:443;
      proxy_set_header X-Forwarded-Port 443;
      proxy_set_header X-Forwarded-Server $host;
      proxy_set_header X-Forwarded-Proto https;
    }
  }

  server {
    listen 8443 ssl;
    server_name dashboard.farmos.test;

    location / {
      proxy_pass http://grafana:3000;

      proxy_set_header Host $host;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header X-Forwarded-Host $host:443;
      proxy_set_header X-Forwarded-Port 443;
      proxy_set_header X-Forwarded-Server $host;
      proxy_set_header X-Forwarded-Proto https;
    }
  }

  server {
    listen 8443 ssl;
    server_name git.farmos.test;

    location / {
      proxy_pass http://gitlab:80;

      proxy_set_header Host $host;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header X-Forwarded-Host $host:443;
      proxy_set_header X-Forwarded-Port 443;
      proxy_set_header X-Forwarded-Server $host;
      proxy_set_header X-Forwarded-Proto https;
    }
  }

}

In the case of this example, certificates have been generated with mkcert and a command something like this;

mkdir -p devcerts/git.farmos.test && mkcert -key-file devcerts/git.farmos.test/privkey.pem -cert-file devcerts/git.farmos.test/fullchain.pem git.farmos.test

In production, I use a piece of software called Dehydrated to manage the acquisition/renewal of Let’s Encrypt certificates, but that’s a topic for a different post.

2 Likes


4 Likes

This is great, thanks for sharing @Symbioquine. I’ve been interested to learn how you configured Grafana (and Gitlab?!) to use the farmOS OAuth2 server, glad to see it looks pretty simple… aside from the grant simple_oauth codes permission no other tricks are needed? I should really just give this docker-compose file a try :slight_smile:

2 Likes

Just an update, this strategy still works with the farmOS 2.0.0 stable release with one small modification - the openid scope is broken at the moment in farmOS (TODO: ref link), but all the authorizations above appear to work with that scope removed. (I can’t remember why I needed that in the first place so maybe it was superfluous all along.)

2 Likes