Connecting Synology NAS to AAI@EduHr: SAML hurdles and a local OIDC claim flattener

We recently deployed a department Synology DiskStation (DS223 running DSM 7.4.1) for collaborative research data storage. The requirement was simple: allow researchers from our faculty (FFZG) as well as colleagues from external Croatian academic institutions to log in using their national federated AAI@EduHr credentials, mapping them to local NAS accounts with private home directories, storage quotas, and shared folder ACLs.

We did not want to maintain local passwords, nor did we want to run a heavy external identity broker (like Keycloak or FreeIPA) just for a storage appliance. Synology DSM has native support for both SAML 2.0 and OpenID Connect (OIDC) SSO clients.

However, connecting Synology DSM to SRCE's SimpleSAMLphp-based AAI@EduHr identity federation revealed two interesting protocol mismatches that caused both SAML and OIDC to fail out of the box. Here is what went wrong and how we solved it with a 100-line local claim flattener daemon.

The Synology Authorization Model

Before looking at the federation protocols, it is important to understand how Synology DSM handles SSO authorization:

  • No Just-In-Time (JIT) provisioning: DSM does not auto-create local accounts upon successful SSO callback. If an authenticated user does not already match a provisioned DSM account, login fails with an authorization error. This is actually a great security boundary: uninvited university users cannot access the NAS.
  • No '@' characters in local usernames: Synology reserves the @ character exclusively for Active Directory and LDAP domain bindings (e.g. user@domain). Attempting to create a local user with @ fails with error code 0x0D00.

This immediately created a constraint: whatever identifier the upstream IdP passes as the username, it must match our local Unix username (e.g. dpavlin), not a scoped email-like principal.

Attempt 1: SAML 2.0 (The NameID Dilemma)

SAML 2.0 is the bedrock of AAI@EduHr and eduGAIN. Synology DSM provides a native SAML Service Provider (SP), so we registered the NAS in SRCE's Registar resursa.

In DSM's SAML implementation, DSM parses <saml:NameID> and unconditionally treats it as the username to authorize.

However, the Registar resursa web dropdown only allows two options for the SAML NameID attribute:

  1. hrEduPersonUniqueID -- Formatted as username@institution.hr. Because DSM local accounts reject @, this cannot match a local account without joining the NAS to an Active Directory domain.
  2. hrEduPersonPersistentID -- A pseudonymous 32-character hexadecimal hash.

Using hrEduPersonPersistentID resulted in DSM attempting to look up the hash as the local account, failing immediately:

User [303a3b0f72c5e29bcbdf35cab3826e62] failed to sign in to [DSM] via [sso] due to authorization failure.

The standard LDAP short username attribute uid exists in the directory, but it cannot be selected as the SAML NameID through the standard self-service registry UI.

Attempt 2: Switching to OpenID Connect (OIDC)

Synology's OIDC SSO client has one critical advantage over its SAML implementation: a configurable "Username claim" field. Instead of being locked to the opaque sub claim, you can tell DSM which claim in the token/userinfo payload represents the username!

We requested an OIDC client registration in Registar resursa, requested the uid scope, and SRCE approved it.

We configured DSM's OIDC client:

  • Well-known URL: https://login.aaiedu.hr/.well-known/openid-configuration
  • Scopes: openid profile email uid
  • Username claim: uid

We clicked "Sign in with SSO", authenticated at https://login.aaiedu.hr/, were redirected back to the NAS, and... DSM popped up an error:

"Your SSO is not properly configured."

The Root Cause: JSON Array vs Scalar String Mismatch

Inspecting /var/log/messages on the Synology NAS revealed the exact C++ stack trace from libsynosso.so:

synoscgi_SYNO.API.Auth_7_login: (json_value.cpp:905)JSON_FAIL_MESSAGE("Type is not convertible to string")
synoscgi_SYNO.API.Auth_7_login: oidc_auth.cpp:70 Get sso User failed. Reason: Type is not convertible to string
synoscgi_SYNO.API.Auth_7_login: pam_syno_sso.cpp:123 (euid=0) Failed [username.empty()]
synoscgi_SYNO.API.Auth_7_login: login.c:1162 Get uid/gid fail for user []. [0x1D00 user_db_get.c:36]

Why did oidc_auth.cpp throw Type is not convertible to string?

Let's look at what SRCE's SimpleSAMLphp OIDC module returns at /userinfo:

{
  "sub": "303a3b0f72c5e29bcbdf35cab3826e62",
  "uid": [
    "dpavlin"
  ],
  "hrEduPersonUniqueID": [
    "dpavlin@ffzg.hr"
  ],
  "mail": [
    "dpavlin@ffzg.hr"
  ]
}

Because SimpleSAMLphp maps LDAP attributes (which are inherently multi-valued arrays in directory schemas), it encodes all user claims as JSON arrays. The only primitive scalar string in the payload is sub.

Synology DSM's compiled C++ OIDC handler takes the configured Username claim and calls val.asString(), since it's Json::arrayValue, jsoncpp throws an exception, leaving the extracted username empty and terminating the authentication flow.

The Solution: A Local OIDC Claim Flattener

We did not want to set up an external virtual machine or a heavyweight proxy just to unwrap a single JSON array. The NAS runs Linux, Nginx, and Python 3.

We deployed a small Python HTTP daemon on 127.0.0.1:8089 that acts as an in-line bridge between DSM and AAI@EduHr:

  1. OIDC Discovery: Intercepts /.well-known/openid-configuration from AAI@EduHr and overrides only userinfo_endpoint to point through our local Nginx bridge.
  2. Userinfo Proxy: Intercepts /userinfo, forwards the incoming Bearer token to https://login.aaiedu.hr/sso/module.php/oidc/userinfo, parses the response, and flattens single-element arrays into scalar strings:
    {"uid": ["dpavlin"]}  -->  {"uid": "dpavlin"}
  3. Zero Touch on Credentials: All user authentication redirects (authorization_endpoint) and token exchanges (token_endpoint) remain direct between DSM/browser and login.aaiedu.hr.

Implementation Details

1. The Flattener Daemon (/usr/local/bin/aai-oidc-flattener.py)

#!/usr/bin/env python3
import sys
import json
import urllib.request
import urllib.error
from http.server import HTTPServer, BaseHTTPRequestHandler
from datetime import datetime

UPSTREAM_WELLKNOWN = "https://login.aaiedu.hr/.well-known/openid-configuration"
UPSTREAM_USERINFO = "https://login.aaiedu.hr/sso/module.php/oidc/userinfo"
PUBLIC_USERINFO_URL = "https://XXX-nas.ffzg.unizg.hr:5001/aai-oidc/userinfo"

def log(msg):
    print(f"[{datetime.now().isoformat()}] [aai-oidc-flattener] {msg}", flush=True)

class OIDCFlattenerHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        path = self.path.split("?")[0]
        if path in ("/.well-known/openid-configuration", "/aai-oidc/.well-known/openid-configuration"):
            self.handle_wellknown()
        elif path in ("/userinfo", "/aai-oidc/userinfo"):
            self.handle_userinfo()
        else:
            self.send_error(404, "Endpoint not found")

    def handle_wellknown(self):
        req = urllib.request.Request(UPSTREAM_WELLKNOWN, headers={"User-Agent": "Synology-AAI-OIDC-Bridge/1.0"})
        with urllib.request.urlopen(req, timeout=10) as resp:
            data = json.loads(resp.read().decode("utf-8"))
        
        # Override userinfo_endpoint to route through this bridge
        data["userinfo_endpoint"] = PUBLIC_USERINFO_URL
        payload = json.dumps(data, indent=2).encode("utf-8")
        
        self.send_response(200)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(payload)))
        self.end_headers()
        self.wfile.write(payload)

    def handle_userinfo(self):
        auth_header = self.headers.get("Authorization")
        if not auth_header:
            self.send_error(401, "Missing Authorization header")
            return

        req = urllib.request.Request(
            UPSTREAM_USERINFO,
            headers={"Authorization": auth_header, "User-Agent": "Synology-AAI-OIDC-Bridge/1.0"}
        )
        with urllib.request.urlopen(req, timeout=10) as resp:
            claims = json.loads(resp.read().decode("utf-8"))

        # Flatten single-item array claims into primitive strings
        flattened = {}
        for k, v in claims.items():
            if isinstance(v, list) and len(v) == 1:
                flattened[k] = v[0]
            else:
                flattened[k] = v

        log(f"Flattened user claim uid: {flattened.get('uid')}")
        payload = json.dumps(flattened).encode("utf-8")

        self.send_response(200)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(payload)))
        self.end_headers()
        self.wfile.write(payload)

if __name__ == "__main__":
    server = HTTPServer(("127.0.0.1", 8089), OIDCFlattenerHandler)
    server.serve_forever()

2. Nginx Reverse Proxy Route (/etc/nginx/conf.d/dsm.oidc-bridge.conf)

Synology terminates TLS on port 5001 with its Let's Encrypt certificate. We pass the /aai-oidc/ prefix directly to our local Python daemon:

location ^~ /aai-oidc/ {
    proxy_pass http://127.0.0.1:8089/;
    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-Proto $scheme;
}

3. Systemd Unit (/etc/systemd/system/aai-oidc-flattener.service)

[Unit]
Description=AAI@EduHr OIDC Claim Flattener Bridge for Synology DSM
After=network.target nginx.service

[Service]
Type=simple
User=root
ExecStart=/usr/bin/python3 /usr/local/bin/aai-oidc-flattener.py
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

4. DSM Configuration

In Synology DSM (Control Panel > Domain/LDAP > SSO Client > OpenID Connect), configure:

  • Profile name: AAIEduHr
  • Well-known URL: https://XXX-nas.ffzg.unizg.hr:5001/aai-oidc/.well-known/openid-configuration
  • Application ID / Secret: As assigned in Registar resursa
  • Scope: openid profile email uid
  • Username claim: uid

Verification

With the flattener running, clicking "Sign in with SSO" initiates the authorization code grant flow:

[2026-10-01T08:58:37.382015] [aai-oidc-flattener] Serving modified openid-configuration...
[2026-10-01T08:58:43.084128] [aai-oidc-flattener] Fetching userinfo from upstream AAI...
[2026-10-01T08:58:43.208491] [aai-oidc-flattener] Raw upstream claims received: ['sub', 'uid', 'hrEduPersonUniqueID', 'mail']
[2026-10-01T08:58:43.209102] [aai-oidc-flattener] Flattened user claim uid: dpavlin
[2026-10-01T08:58:43.210411] [aai-oidc-flattener] Successfully returned flattened userinfo to DSM

Synology DSM's oidc_auth.cpp receives "uid": "dpavlin" as a scalar string, extracts the username without throwing an exception, finds the local DSM user dpavlin, and immediately creates a valid DSM session.

Summary

If you are integrating Synology DSM with an enterprise or academic SimpleSAMLphp IdP:

  • SAML will fight you if your IdP only exposes user@domain or opaque hashes as the NameID, because DSM local accounts strictly forbid @.
  • OIDC lets you select an arbitrary claim like uid, but SimpleSAMLphp's LDAP schema heritage will emit arrays ({"uid": ["user"]}), which crashes DSM's C++ JSON parser.
  • A tiny local Python proxy rewrites just enough of the discovery metadata to unpack the array, giving you clean federated SSO with zero external server dependencies.