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.