OpenBSD version: Sadly not... :-(
Arch:            Any
NSFP:            Is anything these days?

I tend to hardly write blog article these days…

…and when I do, it is usually about not so fun things. Well, given that I enjoyed a so called “day off” today, I actually had some time to deal with fun things (for values of):

Setting up Navidrome, and making it play well with Open-ID via Keycloak, while also allowing Apps and local clients to authenticate against the same user database…

(We will focus on the technical aspects here; Populating the ‘database’ is left as an excercise to the avid reader…)

Making Sounds

Navidrome is, more or less, what one could call a ‘self-hosted Spotify’. The docs are relatively self-explanatory.

It also supports my favorit way of authentiation: Via an authenticating reverse proxy in front of it.

This means that all I have to do to get it hooked up to my existing authentication system, is using my standard puppet setup, which provisions an OAuth2-Proxy together with Nginx as the Webserver in front of, essentially, any funny thing I amy want to reverse proxy through it.

The Problem: Connecting Apps

The problem now is that subsonic, the OpenSubsonic API is a bit muddy when it comes to authentication. There seems to be some work on ‘proper’ authn/authz, but that would also have to be supported in Navidrome. It also seems like the API currently kind of needs clear-text passwords… This is what I actually want to prevent by using Keycloak.

Given that ‘Open-ID authentication’ is not really a standard (Desktop)App feature for Subsonic API enabled clients, I was left with the question: “Is it possible to work around the subsonic client API password weakness?” (as seemingly some people before me.)

The thing about authentication…

Navidrome does, pretty much, not do any authorization; Effectively, the first user logging in gets admin rights, and that’s it. For authentication, one can either use an internal database, or the aforementioned proxy auth.

That essentially works by the reverse proxy setting a header (X-User in my case), and ‘taking care’ of the authentication part. In my case, that is nginx with Open-ID via OAuth2-Proxy and a Keycloak instance.

The API seems to support two kinds of authentication; One is the “old” style, which just plainly stacks username and password into GET parameters. The “more modern” version seems to derive a temporary secret from a shared secret (i.e.: the users’ password).

With the older auth version, I would now see requests of the form:

https://sounds.example.com/rest/ping?u=username&p=passwod&t=...&s=...&f=json&v=1.8.0&c=NavidromeUI

This, of course, is a ‘not necessarily smart choice’ of authentication, given that–despite it technically being protected by TLS–this approach may lead to passwords being put into system logs.

However… it is also a way to get this working…

An idea emerges

With the above, we may actually be able to ‘get something going’ here using Nginx’ subrequest_authentication (which is already in use for Open-ID authentication).

  • I need to add an auth callback to nginx for the API/REST endpoints used by apps
  • I need to pass the request URI containing username/password to that endpoint
  • I can then use it in the endpoint to check authentication and, if successful, also set the X-User header.

Theoretically, that should work.

My Keycloak–for several services–also feeds into LDAP; And doing some LDAP authentication should be pretty much doable…

Nginx Configuration

To make my life easier, I decided to move the app edpoint to another vhost; This means that I do not have to deal with ‘dual authentication; i.e., having to make /api and /rest work with the oauth2-proxy callback and whatever I will fiddle here at the same time. (Technically, this should be possible; But… well. Lazy.)

The needed configuration then is also pretty simple; The magic happens in three location statements (with a bit of redundant content… cause they fall out of my puppet):

  
  # The auth end-point; Essentially standard PHP stuff.
  location /subsonic-auth/ {
    index     index.html index.htm index.php;
    # make this only available for internal/auth-callback requests
    internal;
    fastcgi_split_path_info ^(.+\.php)(/.*)$;
    set $path_info $fastcgi_path_info;
    try_files $fastcgi_script_name index.php =404;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_param PATH_INFO $fastcgi_path_info;
    fastcgi_param HTTPS on;
    fastcgi_param modHeadersAvailable true;
    fastcgi_param front_controller_active true;
    fastcgi_pass php-handler;
    fastcgi_intercept_errors on;
    fastcgi_request_buffering off;
    fastcgi_max_temp_file_size 0;
    # Turning off logging to not store passwords in... well... logs
    access_log off;
    log_not_found off;
  }

  # The actually similar resource statements;
  location /api/ {
    index     index.html index.htm index.php;
    # standard proxy pass
    proxy_pass http://127.0.0.1:4533;
    # where to send auth requests; If it returns 200 -> ok; If 403 -> deny
    auth_request /subsonic-auth/auth/index.php;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header Host $host;
    # Set the X-User header based on the X-User header returned by the auth
    # end-point.
    auth_request_set $user   $upstream_http_x_user;
    proxy_set_header X-User  $user;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection upgrade;
    # Turning off logging to not store passwords in... well... logs
    access_log off;
    log_not_found off;
  }

  location /rest/ {
    index     index.html index.htm index.php;
    # standard proxy pass
    proxy_pass http://127.0.0.1:4533;
    # where to send auth requests; If it returns 200 -> ok; If 403 -> deny
    auth_request /subsonic-auth/auth/index.php;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header Host $host;
    # Set the X-User header based on the X-User header returned by the auth
    # end-point.
    auth_request_set $user   $upstream_http_x_user;
    proxy_set_header X-User  $user;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection upgrade;
    # Turning off logging to not store passwords in... well... logs
    access_log off;
    log_not_found off;
  }

Now… we need to get the auth endpoint working… and it might have already been hinted it… we will be using PHP for that… (well,… it … works?)

Going full PHP…

Ok, PHP is not really ‘a popular choice’; But: It works. Usually. Especially for doing stupid things. And… well. That’s… that’s what I do…

So, what do we need:

  • We need the original request URI (we get that from $_SERVER['REQUEST_URI']).
  • We need to test whether the credentials are correct (i.e., try binding against an LDAP)
  • If so, we need to return 200 and set the X-User header
  • If not, we return 403

I found this rather simple PHP LDAP Authentication example by Anthony Broadbent on GitHub, which pretty much illustrates the basics of this. Enriching it with some additional details was then rather straight-forward:

<?php
	$get_parm = array();
	// Check we actually have a REQUEST_URI; Otherwise -> 403
	if (array_key_exists('REQUEST_URI', $_SERVER)) {
		parse_str($_SERVER['REQUEST_URI'], $get_parm);
	} else {
		http_response_code(403);
		die('Access Denied');
	};
	// If there is no u and p in the request URI -> 403
	if ( ! array_key_exists('u', $get_parm) || ! array_key_exists('p', $get_parm)) {
		http_response_code(403);
		die('Access Denied');
	}

	// Kind of redundant and taken from the example code 
	$username = ldap_escape($get_parm['u'], "", LDAP_ESCAPE_DN );
	$password = $get_parm['p'];

	// LDAP settings
	$ldapconfig['host'] = 'ldaps://ldap.example.com';
	$ldapconfig['port'] = '636';
	$ldapconfig['basedn'] = 'dc=example,dc=com';
	$ldapconfig['usersdn'] = 'ou=Users';
	
	$ds=ldap_connect($ldapconfig['host'], $ldapconfig['port']);

	ldap_set_option($ds, LDAP_OPT_PROTOCOL_VERSION, 3);
	ldap_set_option($ds, LDAP_OPT_REFERRALS, 0);
	ldap_set_option($ds, LDAP_OPT_NETWORK_TIMEOUT, 10);

	// We are simply testing if we can bind with a user; This may not work for
	// LDAP servers that require clients to be authenticated to try to authenticate
	// as a user; But it does for me. Hence: Just create the bindDN here.
	$dn="uid=".$username.",".$ldapconfig['usersdn'].",".$ldapconfig['basedn'];

	// If we can bind -> set header and return 200
	if ($bind=ldap_bind($ds, $dn, $password)) {
		header('X-User: '.$username);
		http_response_code(200);
		die('Ok');
	} else {
		// otherwise return 403
		http_response_code(403);
		die('Access Denied');
	}
	// safety-catch-all that should never be reached.
	http_response_code(403);
	die('Access Denied');

?>

Update: Thanks mortzu for two bugfixes.
Update II: Turns out, reading the manual correctly helps; It should be $username = ldap_escape($get_parm['u'], "", LDAP_ESCAPE_DN ); instead of $username = ldap_escape($get_parm['u'], LDAP_ESCAPE_DN );. Sorry to everyone who got confused in the meantime. Fixed now. ;-)

This code is not my proudest moment; Also, there probably should be some input sanitation around u and p; However, as this plainly goes into the binddn/auth-string, i can currently not really think of an easy way to break something by injecting random foo (like: adding in a search string, as it does not fill a filter).

If somebody can think of something, let me know, please. ;-)

In the end… great success…

So… with those two things in place… I can now use a standard subsonic API compliant app to connect to my navidrome instance, authenticating against the same LDAP that supplies the Keycloak for OpenID with credentials.

Real OpenID would certainly be cooler… buuuut… well.

It works, the user-base will likely never go beyond like… one or two… users… and most importantly: It works. :-D