From 2a319001bc16cabfc937fb7a2d520e27e1837955 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Wed, 14 Jan 2026 16:40:21 -0500 Subject: [PATCH 1/6] chore: replace graphql server --- .../snapshots/rc.nginx.modified.snapshot | 2 +- .../modifications/patches/rc-nginx.patch | 13 +++ .../modifications/rc-nginx.modification.ts | 13 ++- plugin/plugins/dynamix.unraid.net.plg | 66 +++++++++++++ .../dynamix.unraid.net/etc/rc.d/rc.unraid | 88 +++++++++++++++++ .../etc/rc.d/rc6.d/K30unraid-core | 7 ++ .../dynamix.unraid.net/install/doinst.sh | 2 +- .../install/scripts/verify_install.sh | 12 ++- web/__test__/components/SsoButton.test.ts | 98 +------------------ web/src/components/sso/useSsoAuth.ts | 48 +-------- 10 files changed, 202 insertions(+), 147 deletions(-) create mode 100755 plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid create mode 100644 plugin/source/dynamix.unraid.net/etc/rc.d/rc6.d/K30unraid-core diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/snapshots/rc.nginx.modified.snapshot b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/snapshots/rc.nginx.modified.snapshot index 7c555fda54..cdab15b5d8 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/snapshots/rc.nginx.modified.snapshot +++ b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/snapshots/rc.nginx.modified.snapshot @@ -420,7 +420,7 @@ build_locations(){ location /graphql { allow all; error_log /dev/null crit; - proxy_pass http://unix:/var/run/unraid-api.sock:/graphql; + proxy_pass http://unix:/var/run/unraid-core.sock:/graphql; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header Upgrade $http_upgrade; diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/patches/rc-nginx.patch b/api/src/unraid-api/unraid-file-modifier/modifications/patches/rc-nginx.patch index fbac95e0bc..9eea949545 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/patches/rc-nginx.patch +++ b/api/src/unraid-api/unraid-file-modifier/modifications/patches/rc-nginx.patch @@ -44,6 +44,19 @@ Index: /etc/rc.d/rc.nginx T=' ' if check && [[ $1 == lo ]]; then if [[ $IPV4 == yes ]]; then +@@ -400,11 +418,11 @@ + # my servers proxy + # + location /graphql { + allow all; + error_log /dev/null crit; +- proxy_pass http://unix:/var/run/unraid-api.sock:/graphql; ++ proxy_pass http://unix:/var/run/unraid-core.sock:/graphql; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_cache_bypass $http_upgrade; @@ -566,11 +584,11 @@ # extract common name from cert CERTNAME=$(openssl x509 -noout -subject -nameopt multiline -in $CERTPATH | sed -n 's/ *commonName *= //p') diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.ts index 6b1c717a3a..1e21880152 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.ts +++ b/api/src/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.ts @@ -29,9 +29,9 @@ export default class RcNginxModification extends FileModification { throw new Error(`File ${this.filePath} not found.`); } const fileContent = await readFile(this.filePath, 'utf8'); - if (!fileContent.includes('MYSERVERS=')) { - throw new Error(`MYSERVERS not found in the file; incorrect target?`); - } + // if (!fileContent.includes('MYSERVERS=')) { + // throw new Error(`MYSERVERS not found in the file; incorrect target?`); + // } let newContent = fileContent.replace( 'MYSERVERS="/boot/config/plugins/dynamix.my.servers/myservers.cfg"', @@ -68,6 +68,11 @@ check_remote_access(){ `if [[ -L /usr/local/sbin/unraid-api ]] && check_remote_access; then` ); + newContent = newContent.replace( + 'proxy_pass http://unix:/var/run/unraid-api.sock:/graphql;', + 'proxy_pass http://unix:/var/run/unraid-core.sock:/graphql;' + ); + newContent = newContent.replace( 'for NET in ${!NET_FQDN6[@]}; do', 'for NET in "${!NET_FQDN6[@]}"; do' @@ -91,7 +96,7 @@ check_remote_access(){ } async shouldApply(): Promise { - const { shouldApply, reason } = await super.shouldApply(); + const { shouldApply, reason } = await super.shouldApply({ checkOsVersion: false }); return { shouldApply, reason, diff --git a/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index 6e8a60e695..da2b61b70c 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -9,6 +9,10 @@ + + + + @@ -52,6 +56,12 @@ exit 0 &txz_sha256; + + + &core_txz_url; + &core_txz_sha256; + + @@ -320,6 +330,21 @@ exit 0 fi fi + # Stop and remove Unraid Core package + if [ -x "/etc/rc.d/rc.unraid" ]; then + echo "Stopping Unraid Core..." + /etc/rc.d/rc.unraid stop || echo "Warning: Failed to stop Unraid Core" + fi + + core_pkg_installed=$(ls -1 /var/log/packages/unraid-* 2>/dev/null | head -1) + if [ -n "$core_pkg_installed" ]; then + core_pkg_basename=$(basename "$core_pkg_installed") + echo "Removing core package: $core_pkg_basename" + removepkg --terse "$core_pkg_basename" + else + echo "No Unraid Core package found" + fi + # File restoration function echo "Restoring files..." @@ -404,6 +429,9 @@ exit 0 PKG_FILE="&source;" # Full path to the package file including .txz extension PKG_URL="&txz_url;" # URL where package was downloaded from PKG_NAME="&txz_name;" # Name of the package file + CORE_PKG_FILE="&core_source;" + CORE_PKG_URL="&core_txz_url;" + CORE_PKG_NAME="&core_txz_name;" CONNECT_API_VERSION="&api_version;" # Version of API included with Connect @@ -599,6 +658,13 @@ echo "If no additional messages appear within 30 seconds, it is safe to refresh /etc/rc.d/rc.unraid-api start echo "Unraid API service started" +if [ -x "/etc/rc.d/rc.unraid" ]; then + echo "Starting Unraid Core service" + /etc/rc.d/rc.unraid start + echo "Unraid Core service started" +else + echo "Warning: rc.unraid not found; core service not started" +fi echo "✅ Installation is complete, it is safe to close this window" echo exit 0 diff --git a/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid b/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid new file mode 100755 index 0000000000..d7c35b4abd --- /dev/null +++ b/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid @@ -0,0 +1,88 @@ +#!/bin/bash +# /etc/rc.d/rc.unraid +# Unraid Phoenix Application Service + +APP_DIR="/usr/local/unraid" +RELEASE_BIN="$APP_DIR/_build/prod/rel/unraid/bin/unraid" +CONFIG_DIR="/boot/config/unraid" +SOCKET_PATH="/var/run/unraid-core.sock" +LOG_PATH="${UNRAID_LOG_PATH:-/var/log/unraid-core.log}" + +# Load user env if exists +[ -f "$CONFIG_DIR/env" ] && source "$CONFIG_DIR/env" + +# Ensure config and log directories exist +mkdir -p "$CONFIG_DIR" +mkdir -p "$(dirname "$LOG_PATH")" +touch "$LOG_PATH" + +# Generate secret_key_base if not exists +if [ ! -f "$CONFIG_DIR/secret_key_base" ]; then + head -c 64 /dev/urandom | base64 | tr -d '\n' > "$CONFIG_DIR/secret_key_base" + chmod 600 "$CONFIG_DIR/secret_key_base" +fi + +export SECRET_KEY_BASE=$(cat "$CONFIG_DIR/secret_key_base") +export RELEASE_COOKIE=$(cat "$CONFIG_DIR/secret_key_base" | head -c 20) +export UNRAID_CONFIG_DIR="$CONFIG_DIR" +export RUN_ERL_LOG="${RUN_ERL_LOG:-$LOG_PATH}" +export RELEASE_LOG_DIR="${RELEASE_LOG_DIR:-$(dirname "$LOG_PATH")}" +export RELEASE_NODE="${UNRAID_RELEASE_NODE:-unraid}" +export RELEASE_DISTRIBUTION="${UNRAID_RELEASE_DISTRIBUTION:-sname}" + +# Import user's runtime.exs if exists +[ -f "$CONFIG_DIR/runtime.exs" ] && export RELEASE_CONFIG_DIR="$CONFIG_DIR" + +# Socket/port configuration +if [ -n "${UNRAID_PORT:-}" ]; then + export PHX_PORT="$UNRAID_PORT" +else + export PHX_SOCKET="${UNRAID_SOCKET:-$SOCKET_PATH}" +fi + +start() { + echo -n "Starting Unraid... " + [ -S "$SOCKET_PATH" ] && rm -f "$SOCKET_PATH" + "$RELEASE_BIN" daemon + echo "done" +} + +stop() { + echo -n "Stopping Unraid... " + "$RELEASE_BIN" stop 2>/dev/null || true + [ -S "$SOCKET_PATH" ] && rm -f "$SOCKET_PATH" + echo "done" +} + +restart() { + stop + sleep 2 + start +} + +status() { + "$RELEASE_BIN" pid >/dev/null 2>&1 && echo "Running" || echo "Stopped" +} + +rollback() { + if [ -d "/usr/local/unraid.prev" ]; then + echo "Rolling back to previous version..." + stop + rm -rf /usr/local/unraid + mv /usr/local/unraid.prev /usr/local/unraid + start + echo "Rollback complete" + else + echo "No previous version available" + exit 1 + fi +} + +case "${1:-}" in + start) start ;; + stop) stop ;; + restart) restart ;; + status) status ;; + rollback) rollback ;; + *) echo "Usage: $0 {start|stop|restart|status|rollback}" ;; +esac diff --git a/plugin/source/dynamix.unraid.net/etc/rc.d/rc6.d/K30unraid-core b/plugin/source/dynamix.unraid.net/etc/rc.d/rc6.d/K30unraid-core new file mode 100644 index 0000000000..82a004bbcd --- /dev/null +++ b/plugin/source/dynamix.unraid.net/etc/rc.d/rc6.d/K30unraid-core @@ -0,0 +1,7 @@ +#!/bin/sh +# Stop Unraid Core on shutdown/reboot + +if [ -x /etc/rc.d/rc.unraid ]; then + echo "Stopping Unraid Core..." + /etc/rc.d/rc.unraid stop +fi diff --git a/plugin/source/dynamix.unraid.net/install/doinst.sh b/plugin/source/dynamix.unraid.net/install/doinst.sh index e18f5f64eb..79b6e2292f 100644 --- a/plugin/source/dynamix.unraid.net/install/doinst.sh +++ b/plugin/source/dynamix.unraid.net/install/doinst.sh @@ -6,7 +6,7 @@ backup_file_if_exists() { fi } -for f in etc/rc.d/rc6.d/K*unraid-api etc/rc.d/rc6.d/K*flash-backup; do +for f in etc/rc.d/rc6.d/K*unraid-api etc/rc.d/rc6.d/K*unraid-core etc/rc.d/rc6.d/K*flash-backup; do [ -e "$f" ] && chmod 755 "$f" done diff --git a/plugin/source/dynamix.unraid.net/usr/local/share/dynamix.unraid.net/install/scripts/verify_install.sh b/plugin/source/dynamix.unraid.net/usr/local/share/dynamix.unraid.net/install/scripts/verify_install.sh index 0731bd976e..107d44aa87 100755 --- a/plugin/source/dynamix.unraid.net/usr/local/share/dynamix.unraid.net/install/scripts/verify_install.sh +++ b/plugin/source/dynamix.unraid.net/usr/local/share/dynamix.unraid.net/install/scripts/verify_install.sh @@ -42,10 +42,12 @@ echo "Performing comprehensive installation verification..." # Define critical files to check (POSIX-compliant, no arrays) CRITICAL_FILES="/usr/local/bin/unraid-api /etc/rc.d/rc.unraid-api +/etc/rc.d/rc.unraid /usr/local/emhttp/plugins/dynamix.my.servers/scripts/gitflash_log" # Define critical directories to check (POSIX-compliant, no arrays) CRITICAL_DIRS="/usr/local/unraid-api +/usr/local/unraid /var/log/unraid-api /usr/local/emhttp/plugins/dynamix.my.servers /usr/local/emhttp/plugins/dynamix.unraid.net @@ -159,6 +161,14 @@ else SHUTDOWN_ERRORS=$((SHUTDOWN_ERRORS + 1)) fi +# Check for unraid-core shutdown script +if [ -x "/etc/rc.d/rc6.d/K30unraid-core" ]; then + printf '✓ Shutdown script for unraid-core exists and is executable\n' +else + printf '✗ Shutdown script for unraid-core missing or not executable\n' + SHUTDOWN_ERRORS=$((SHUTDOWN_ERRORS + 1)) +fi + # Check for rc0.d symlink or directory if [ -L "/etc/rc.d/rc0.d" ]; then printf '✓ rc0.d symlink exists\n' @@ -206,4 +216,4 @@ else echo "Please review the errors above and contact support if needed." # We don't exit with error as this is just a verification script exit 0 -fi \ No newline at end of file +fi diff --git a/web/__test__/components/SsoButton.test.ts b/web/__test__/components/SsoButton.test.ts index c88c622ae0..cf3f7cd7ac 100644 --- a/web/__test__/components/SsoButton.test.ts +++ b/web/__test__/components/SsoButton.test.ts @@ -253,14 +253,7 @@ describe('SsoButtons', () => { const button = wrapper.find('button'); await button.trigger('click'); - // Should set state and provider in sessionStorage - expect(sessionStorage.setItem).toHaveBeenCalledWith('sso_state', expect.any(String)); - expect(sessionStorage.setItem).toHaveBeenCalledWith('sso_provider', 'unraid-net'); - - const generatedState = (sessionStorage.setItem as Mock).mock.calls[0][1]; - const redirectUri = `${mockLocation.origin}/graphql/api/auth/oidc/callback`; - const expectedUrl = `/graphql/api/auth/oidc/authorize/unraid-net?state=${encodeURIComponent(generatedState)}&redirect_uri=${encodeURIComponent(redirectUri)}`; - + const expectedUrl = `/auth/sso/unraid-net`; expect(mockLocation.href).toBe(expectedUrl); }); @@ -349,95 +342,6 @@ describe('SsoButtons', () => { expect(mockHistory.replaceState).toHaveBeenCalledWith({}, 'Mock Title', expectedUrl); }); - it('redirects to OIDC callback endpoint when code and state are present', async () => { - const mockProviders = [ - { - id: 'unraid-net', - name: 'Unraid.net', - buttonText: 'Log In With Unraid.net', - }, - ]; - - mockUseQuery.mockReturnValue({ - result: { value: { publicOidcProviders: mockProviders } }, - refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }), - }); - - const mockCode = 'mock_auth_code'; - const mockState = 'mock_session_state_value'; - - mockLocation.search = `?code=${mockCode}&state=${mockState}`; - mockLocation.pathname = '/login'; - - mount(SsoButtons, { - global: { - plugins: [createTestI18n()], - stubs: { - SsoProviderButton: SsoProviderButtonStub, - Button: { template: '' }, - }, - }, - }); - - await flushPromises(); - - // Should redirect to the OIDC callback endpoint - const expectedUrl = `/graphql/api/auth/oidc/callback?code=${encodeURIComponent(mockCode)}&state=${encodeURIComponent(mockState)}`; - expect(mockLocation.href).toBe(expectedUrl); - }); - - it('handles HTTPS with non-standard port correctly', async () => { - const mockProviders = [ - { - id: 'tsidp', - name: 'Tailscale IDP', - buttonText: 'Sign in with Tailscale', - buttonIcon: null, - buttonVariant: 'secondary', - buttonStyle: null, - }, - ]; - - // Set up location with HTTPS and non-standard port - mockLocation.protocol = 'https:'; - mockLocation.host = 'unraid.mytailnet.ts.net:1443'; - mockLocation.origin = 'https://unraid.mytailnet.ts.net:1443'; - - mockUseQuery.mockReturnValue({ - result: { value: { publicOidcProviders: mockProviders } }, - refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }), - }); - - const wrapper = mount(SsoButtons, { - global: { - plugins: [createTestI18n()], - stubs: { - SsoProviderButton: SsoProviderButtonStub, - Button: { template: '' }, - }, - }, - }); - - await flushPromises(); - vi.runAllTimers(); - await flushPromises(); - - const button = wrapper.find('button'); - await button.trigger('click'); - - // Should include the correct redirect URI with HTTPS and port 1443 - const generatedState = (sessionStorage.setItem as Mock).mock.calls[0][1]; - const redirectUri = 'https://unraid.mytailnet.ts.net:1443/graphql/api/auth/oidc/callback'; - const expectedUrl = `/graphql/api/auth/oidc/authorize/tsidp?state=${encodeURIComponent(generatedState)}&redirect_uri=${encodeURIComponent(redirectUri)}`; - - expect(mockLocation.href).toBe(expectedUrl); - - // Reset location mock for other tests - mockLocation.protocol = 'http:'; - mockLocation.host = 'mock-origin.com'; - mockLocation.origin = 'http://mock-origin.com'; - }); - it('handles multiple OIDC providers', async () => { const mockProviders = [ { diff --git a/web/src/components/sso/useSsoAuth.ts b/web/src/components/sso/useSsoAuth.ts index 2b3d1f2573..573bfa45ab 100644 --- a/web/src/components/sso/useSsoAuth.ts +++ b/web/src/components/sso/useSsoAuth.ts @@ -35,19 +35,6 @@ export function useSsoAuth() { form.requestSubmit(); }; - const getStateToken = (): string | null => { - const state = sessionStorage.getItem('sso_state'); - return state ?? null; - }; - - const generateStateToken = (): string => { - const array = new Uint8Array(32); - window.crypto.getRandomValues(array); - const state = Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join(''); - sessionStorage.setItem('sso_state', state); - return state; - }; - const disableFormOnSubmit = () => { const fields = getInputFields(); if (fields?.form) { @@ -63,19 +50,9 @@ export function useSsoAuth() { }; const navigateToProvider = (providerId: string) => { - // Generate state token for CSRF protection - const state = generateStateToken(); - - // Store provider ID separately since state must be alphanumeric only - sessionStorage.setItem('sso_state', state); - sessionStorage.setItem('sso_provider', providerId); - - // Build the redirect URI based on current window location - const redirectUri = `${window.location.protocol}//${window.location.host}/graphql/api/auth/oidc/callback`; - - // Redirect to OIDC authorization endpoint with state token and redirect URI - const authUrl = `/graphql/api/auth/oidc/authorize/${encodeURIComponent(providerId)}?state=${encodeURIComponent(state)}&redirect_uri=${encodeURIComponent(redirectUri)}`; - window.location.href = authUrl; + currentState.value = 'loading'; + error.value = null; + window.location.href = `/auth/sso/${encodeURIComponent(providerId)}`; }; const handleOAuthCallback = async () => { @@ -85,11 +62,8 @@ export function useSsoAuth() { const hashToken = hashParams.get('token'); const hashError = hashParams.get('error'); - // Then check query parameters (for OAuth code/state from provider redirects) + // Then check query parameters (for error/token fallback) const search = new URLSearchParams(window.location.search); - const code = search.get('code') ?? ''; - const state = search.get('state') ?? ''; - const sessionState = getStateToken(); // Check for error in hash (preferred) or query params (fallback) const errorParam = hashError || search.get('error') || ''; @@ -114,21 +88,9 @@ export function useSsoAuth() { return; } - // Handle Unraid.net SSO callback (comes to /login with code and state) - if (code && state && window.location.pathname === '/login') { - currentState.value = 'loading'; - - // Redirect to our OIDC callback endpoint to exchange the code - const callbackUrl = `/graphql/api/auth/oidc/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`; - window.location.href = callbackUrl; + if (window.location.pathname !== '/login') { return; } - - // Error if we have mismatched state - if (code && state && state !== sessionState) { - currentState.value = 'error'; - error.value = t('sso.useSsoAuth.invalidCallbackParameters'); - } } catch (err) { console.error('Error fetching token', err); currentState.value = 'error'; From 537f78350cea9661443d88ffbed1c912f41f532e Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Thu, 15 Jan 2026 05:31:56 -0500 Subject: [PATCH 2/6] chore: update core_ release --- plugin/plugins/dynamix.unraid.net.plg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index da2b61b70c..b767b27988 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -10,9 +10,9 @@ - - - + + + From f73919a156e34175aab05ccadb402c2cfdd98f48 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Thu, 15 Jan 2026 06:42:02 -0500 Subject: [PATCH 3/6] fix: sso auth --- .../snapshots/rc.nginx.modified.snapshot | 11 ++++++++ .../modifications/patches/rc-nginx.patch | 28 +++++++++++++++++-- .../modifications/rc-nginx.modification.ts | 8 ++++++ plugin/plugins/dynamix.unraid.net.plg | 2 +- .../dynamix.unraid.net/etc/rc.d/rc.unraid | 1 + 5 files changed, 46 insertions(+), 4 deletions(-) diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/snapshots/rc.nginx.modified.snapshot b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/snapshots/rc.nginx.modified.snapshot index cdab15b5d8..4064118890 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/snapshots/rc.nginx.modified.snapshot +++ b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/snapshots/rc.nginx.modified.snapshot @@ -383,6 +383,17 @@ build_locations(){ include fastcgi_params; } # + # SSO endpoints (public) + location /auth/sso { + allow all; + proxy_pass http://unix:/var/run/unraid-core.sock:; + proxy_http_version 1.1; + 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; + } + # # Redirect to login page on failed authentication (401) # error_page 401 @401; diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/patches/rc-nginx.patch b/api/src/unraid-api/unraid-file-modifier/modifications/patches/rc-nginx.patch index 9eea949545..d615cd2d9a 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/patches/rc-nginx.patch +++ b/api/src/unraid-api/unraid-file-modifier/modifications/patches/rc-nginx.patch @@ -44,7 +44,29 @@ Index: /etc/rc.d/rc.nginx T=' ' if check && [[ $1 == lo ]]; then if [[ $IPV4 == yes ]]; then -@@ -400,11 +418,11 @@ +@@ -363,10 +381,21 @@ + allow all; + try_files /login.php =404; + include fastcgi_params; + } + # ++ # SSO endpoints (public) ++ location /auth/sso { ++ allow all; ++ proxy_pass http://unix:/var/run/unraid-core.sock:; ++ proxy_http_version 1.1; ++ 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; ++ } ++ # + # Redirect to login page on failed authentication (401) + # + error_page 401 @401; + location @401 { + return 302 $scheme://$http_host/login; +@@ -400,11 +429,11 @@ # my servers proxy # location /graphql { @@ -57,7 +79,7 @@ Index: /etc/rc.d/rc.nginx proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_cache_bypass $http_upgrade; -@@ -566,11 +584,11 @@ +@@ -566,11 +595,11 @@ # extract common name from cert CERTNAME=$(openssl x509 -noout -subject -nameopt multiline -in $CERTPATH | sed -n 's/ *commonName *= //p') # define CSP frame-ancestors for cert @@ -70,7 +92,7 @@ Index: /etc/rc.d/rc.nginx WANIP6=$(curl https://wanip6.unraid.net/ 2>/dev/null) fi if [[ $CERTNAME == *\.myunraid\.net ]]; then -@@ -660,14 +678,14 @@ +@@ -660,14 +689,14 @@ echo "NGINX_WANFQDN=\"$WANFQDN\"" >>$INI echo "NGINX_WANFQDN6=\"$WANFQDN6\"" >>$INI # defined if ts_bundle.pem present: diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.ts index 1e21880152..c3f83c603f 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.ts +++ b/api/src/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.ts @@ -73,6 +73,14 @@ check_remote_access(){ 'proxy_pass http://unix:/var/run/unraid-core.sock:/graphql;' ); + if (!newContent.includes('location /auth/sso')) { + newContent = newContent.replace( + '\t# Redirect to login page on failed authentication (401)\n', + // prettier-ignore + `\t# SSO endpoints (public)\n\tlocation /auth/sso {\n\t allow all;\n\t proxy_pass http://unix:/var/run/unraid-core.sock:;\n\t proxy_http_version 1.1;\n\t proxy_set_header Host $host;\n\t proxy_set_header X-Real-IP $remote_addr;\n\t proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\t proxy_set_header X-Forwarded-Proto $scheme;\n\t}\n\t#\n\t# Redirect to login page on failed authentication (401)\n` + ); + } + newContent = newContent.replace( 'for NET in ${!NET_FQDN6[@]}; do', 'for NET in "${!NET_FQDN6[@]}"; do' diff --git a/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index b767b27988..dfc2f11285 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -10,7 +10,7 @@ - + diff --git a/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid b/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid index d7c35b4abd..80f636e20c 100755 --- a/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid +++ b/plugin/source/dynamix.unraid.net/etc/rc.d/rc.unraid @@ -29,6 +29,7 @@ export RUN_ERL_LOG="${RUN_ERL_LOG:-$LOG_PATH}" export RELEASE_LOG_DIR="${RELEASE_LOG_DIR:-$(dirname "$LOG_PATH")}" export RELEASE_NODE="${UNRAID_RELEASE_NODE:-unraid}" export RELEASE_DISTRIBUTION="${UNRAID_RELEASE_DISTRIBUTION:-sname}" +export RELEASE_MODE="${UNRAID_RELEASE_MODE:-interactive}" # Import user's runtime.exs if exists [ -f "$CONFIG_DIR/runtime.exs" ] && export RELEASE_CONFIG_DIR="$CONFIG_DIR" From 9ddae2e3adc5b099572ebef5c42432efc3b3e1f6 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Thu, 15 Jan 2026 08:25:41 -0500 Subject: [PATCH 4/6] fix: sso compat --- .../modifications/patches/rc-nginx.patch | 23 ++++- .../modifications/rc-nginx.modification.ts | 10 +- web/__test__/components/SsoButton.test.ts | 98 ++++++++++++++++++- web/src/components/sso/useSsoAuth.ts | 46 ++++++++- web/src/helpers/create-apollo-client.ts | 5 +- 5 files changed, 174 insertions(+), 8 deletions(-) diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/patches/rc-nginx.patch b/api/src/unraid-api/unraid-file-modifier/modifications/patches/rc-nginx.patch index d615cd2d9a..acbd35f4b6 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/patches/rc-nginx.patch +++ b/api/src/unraid-api/unraid-file-modifier/modifications/patches/rc-nginx.patch @@ -66,20 +66,35 @@ Index: /etc/rc.d/rc.nginx error_page 401 @401; location @401 { return 302 $scheme://$http_host/login; -@@ -400,11 +429,11 @@ +@@ -397,14 +426,26 @@ + nchan_stub_status; + } + # # my servers proxy # ++ location /graphql/api { ++ allow all; ++ proxy_pass http://unix:/var/run/unraid-api.sock:; ++ proxy_http_version 1.1; ++ 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; ++ } location /graphql { allow all; error_log /dev/null crit; - proxy_pass http://unix:/var/run/unraid-api.sock:/graphql; -+ proxy_pass http://unix:/var/run/unraid-core.sock:/graphql; ++ if ($http_upgrade = "websocket") { ++ rewrite ^/graphql$ /graphql/socket break; ++ } ++ proxy_pass http://unix:/var/run/unraid-core.sock:; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_cache_bypass $http_upgrade; -@@ -566,11 +595,11 @@ +@@ -566,11 +607,11 @@ # extract common name from cert CERTNAME=$(openssl x509 -noout -subject -nameopt multiline -in $CERTPATH | sed -n 's/ *commonName *= //p') # define CSP frame-ancestors for cert @@ -92,7 +107,7 @@ Index: /etc/rc.d/rc.nginx WANIP6=$(curl https://wanip6.unraid.net/ 2>/dev/null) fi if [[ $CERTNAME == *\.myunraid\.net ]]; then -@@ -660,14 +689,14 @@ +@@ -660,14 +701,14 @@ echo "NGINX_WANFQDN=\"$WANFQDN\"" >>$INI echo "NGINX_WANFQDN6=\"$WANFQDN6\"" >>$INI # defined if ts_bundle.pem present: diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.ts index c3f83c603f..28d396f890 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.ts +++ b/api/src/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.ts @@ -70,7 +70,7 @@ check_remote_access(){ newContent = newContent.replace( 'proxy_pass http://unix:/var/run/unraid-api.sock:/graphql;', - 'proxy_pass http://unix:/var/run/unraid-core.sock:/graphql;' + 'if ($http_upgrade = "websocket") {\n\t rewrite ^/graphql$ /graphql/socket break;\n\t }\n\t proxy_pass http://unix:/var/run/unraid-core.sock:;' ); if (!newContent.includes('location /auth/sso')) { @@ -81,6 +81,14 @@ check_remote_access(){ ); } + if (!newContent.includes('location /graphql/api')) { + newContent = newContent.replace( + '\t# my servers proxy\n\t#\n\tlocation /graphql {', + // prettier-ignore + `\t# my servers proxy\n\t#\n\tlocation /graphql/api {\n\t allow all;\n\t proxy_pass http://unix:/var/run/unraid-api.sock:;\n\t proxy_http_version 1.1;\n\t proxy_set_header Host $host;\n\t proxy_set_header X-Real-IP $remote_addr;\n\t proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\t proxy_set_header X-Forwarded-Proto $scheme;\n\t}\n\tlocation /graphql {` + ); + } + newContent = newContent.replace( 'for NET in ${!NET_FQDN6[@]}; do', 'for NET in "${!NET_FQDN6[@]}"; do' diff --git a/web/__test__/components/SsoButton.test.ts b/web/__test__/components/SsoButton.test.ts index cf3f7cd7ac..c88c622ae0 100644 --- a/web/__test__/components/SsoButton.test.ts +++ b/web/__test__/components/SsoButton.test.ts @@ -253,7 +253,14 @@ describe('SsoButtons', () => { const button = wrapper.find('button'); await button.trigger('click'); - const expectedUrl = `/auth/sso/unraid-net`; + // Should set state and provider in sessionStorage + expect(sessionStorage.setItem).toHaveBeenCalledWith('sso_state', expect.any(String)); + expect(sessionStorage.setItem).toHaveBeenCalledWith('sso_provider', 'unraid-net'); + + const generatedState = (sessionStorage.setItem as Mock).mock.calls[0][1]; + const redirectUri = `${mockLocation.origin}/graphql/api/auth/oidc/callback`; + const expectedUrl = `/graphql/api/auth/oidc/authorize/unraid-net?state=${encodeURIComponent(generatedState)}&redirect_uri=${encodeURIComponent(redirectUri)}`; + expect(mockLocation.href).toBe(expectedUrl); }); @@ -342,6 +349,95 @@ describe('SsoButtons', () => { expect(mockHistory.replaceState).toHaveBeenCalledWith({}, 'Mock Title', expectedUrl); }); + it('redirects to OIDC callback endpoint when code and state are present', async () => { + const mockProviders = [ + { + id: 'unraid-net', + name: 'Unraid.net', + buttonText: 'Log In With Unraid.net', + }, + ]; + + mockUseQuery.mockReturnValue({ + result: { value: { publicOidcProviders: mockProviders } }, + refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }), + }); + + const mockCode = 'mock_auth_code'; + const mockState = 'mock_session_state_value'; + + mockLocation.search = `?code=${mockCode}&state=${mockState}`; + mockLocation.pathname = '/login'; + + mount(SsoButtons, { + global: { + plugins: [createTestI18n()], + stubs: { + SsoProviderButton: SsoProviderButtonStub, + Button: { template: '' }, + }, + }, + }); + + await flushPromises(); + + // Should redirect to the OIDC callback endpoint + const expectedUrl = `/graphql/api/auth/oidc/callback?code=${encodeURIComponent(mockCode)}&state=${encodeURIComponent(mockState)}`; + expect(mockLocation.href).toBe(expectedUrl); + }); + + it('handles HTTPS with non-standard port correctly', async () => { + const mockProviders = [ + { + id: 'tsidp', + name: 'Tailscale IDP', + buttonText: 'Sign in with Tailscale', + buttonIcon: null, + buttonVariant: 'secondary', + buttonStyle: null, + }, + ]; + + // Set up location with HTTPS and non-standard port + mockLocation.protocol = 'https:'; + mockLocation.host = 'unraid.mytailnet.ts.net:1443'; + mockLocation.origin = 'https://unraid.mytailnet.ts.net:1443'; + + mockUseQuery.mockReturnValue({ + result: { value: { publicOidcProviders: mockProviders } }, + refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }), + }); + + const wrapper = mount(SsoButtons, { + global: { + plugins: [createTestI18n()], + stubs: { + SsoProviderButton: SsoProviderButtonStub, + Button: { template: '' }, + }, + }, + }); + + await flushPromises(); + vi.runAllTimers(); + await flushPromises(); + + const button = wrapper.find('button'); + await button.trigger('click'); + + // Should include the correct redirect URI with HTTPS and port 1443 + const generatedState = (sessionStorage.setItem as Mock).mock.calls[0][1]; + const redirectUri = 'https://unraid.mytailnet.ts.net:1443/graphql/api/auth/oidc/callback'; + const expectedUrl = `/graphql/api/auth/oidc/authorize/tsidp?state=${encodeURIComponent(generatedState)}&redirect_uri=${encodeURIComponent(redirectUri)}`; + + expect(mockLocation.href).toBe(expectedUrl); + + // Reset location mock for other tests + mockLocation.protocol = 'http:'; + mockLocation.host = 'mock-origin.com'; + mockLocation.origin = 'http://mock-origin.com'; + }); + it('handles multiple OIDC providers', async () => { const mockProviders = [ { diff --git a/web/src/components/sso/useSsoAuth.ts b/web/src/components/sso/useSsoAuth.ts index 573bfa45ab..1806b1fcd4 100644 --- a/web/src/components/sso/useSsoAuth.ts +++ b/web/src/components/sso/useSsoAuth.ts @@ -35,6 +35,19 @@ export function useSsoAuth() { form.requestSubmit(); }; + const getStateToken = (): string | null => { + const state = sessionStorage.getItem('sso_state'); + return state ?? null; + }; + + const generateStateToken = (): string => { + const array = new Uint8Array(32); + window.crypto.getRandomValues(array); + const state = Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join(''); + sessionStorage.setItem('sso_state', state); + return state; + }; + const disableFormOnSubmit = () => { const fields = getInputFields(); if (fields?.form) { @@ -52,7 +65,19 @@ export function useSsoAuth() { const navigateToProvider = (providerId: string) => { currentState.value = 'loading'; error.value = null; - window.location.href = `/auth/sso/${encodeURIComponent(providerId)}`; + // Generate state token for CSRF protection + const state = generateStateToken(); + + // Store provider ID separately since state must be alphanumeric only + sessionStorage.setItem('sso_state', state); + sessionStorage.setItem('sso_provider', providerId); + + // Build the redirect URI based on current window location + const redirectUri = `${window.location.protocol}//${window.location.host}/graphql/api/auth/oidc/callback`; + + // Redirect to OIDC authorization endpoint with state token and redirect URI + const authUrl = `/graphql/api/auth/oidc/authorize/${encodeURIComponent(providerId)}?state=${encodeURIComponent(state)}&redirect_uri=${encodeURIComponent(redirectUri)}`; + window.location.href = authUrl; }; const handleOAuthCallback = async () => { @@ -64,6 +89,9 @@ export function useSsoAuth() { // Then check query parameters (for error/token fallback) const search = new URLSearchParams(window.location.search); + const code = search.get('code') ?? ''; + const state = search.get('state') ?? ''; + const sessionState = getStateToken(); // Check for error in hash (preferred) or query params (fallback) const errorParam = hashError || search.get('error') || ''; @@ -88,6 +116,22 @@ export function useSsoAuth() { return; } + // Handle Unraid.net SSO callback (comes to /login with code and state) + if (code && state && window.location.pathname === '/login') { + currentState.value = 'loading'; + + // Redirect to our OIDC callback endpoint to exchange the code + const callbackUrl = `/graphql/api/auth/oidc/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`; + window.location.href = callbackUrl; + return; + } + + // Error if we have mismatched state + if (code && state && state !== sessionState) { + currentState.value = 'error'; + error.value = t('sso.useSsoAuth.invalidCallbackParameters'); + } + if (window.location.pathname !== '/login') { return; } diff --git a/web/src/helpers/create-apollo-client.ts b/web/src/helpers/create-apollo-client.ts index f3e0c0adaf..c45c5491a9 100644 --- a/web/src/helpers/create-apollo-client.ts +++ b/web/src/helpers/create-apollo-client.ts @@ -43,8 +43,11 @@ const wsEndpoint = new URL(httpEndpoint); wsEndpoint.protocol = wsEndpoint.protocol === 'https:' ? 'wss:' : 'ws:'; const DEV_MODE = (globalThis as unknown as { __DEV__: boolean }).__DEV__ ?? false; +const csrfToken = globalThis.csrf_token ?? '0000000000000000'; +wsEndpoint.searchParams.set('_csrf_token', csrfToken); + const headers = { - 'x-csrf-token': globalThis.csrf_token ?? '0000000000000000', + 'x-csrf-token': csrfToken, }; const httpLink = createHttpLink({ From f83aa5cf1306fda1d4a47fd829ea1dab68f4b38e Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Thu, 15 Jan 2026 08:27:50 -0500 Subject: [PATCH 5/6] chore: update unraid_core --- plugin/plugins/dynamix.unraid.net.plg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index dfc2f11285..6e795a94b3 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -10,7 +10,7 @@ - + From 9557998bb3561f670e73066dc215e91fd25554ea Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Thu, 15 Jan 2026 08:35:58 -0500 Subject: [PATCH 6/6] test: update rc.nginx snapshot --- .../__test__/snapshots/rc.nginx.modified.snapshot | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/snapshots/rc.nginx.modified.snapshot b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/snapshots/rc.nginx.modified.snapshot index 4064118890..3b24e4777c 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/snapshots/rc.nginx.modified.snapshot +++ b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/snapshots/rc.nginx.modified.snapshot @@ -428,10 +428,22 @@ build_locations(){ # # my servers proxy # + location /graphql/api { + allow all; + proxy_pass http://unix:/var/run/unraid-api.sock:; + proxy_http_version 1.1; + 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; + } location /graphql { allow all; error_log /dev/null crit; - proxy_pass http://unix:/var/run/unraid-core.sock:/graphql; + if ($http_upgrade = "websocket") { + rewrite ^/graphql$ /graphql/socket break; + } + proxy_pass http://unix:/var/run/unraid-core.sock:; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header Upgrade $http_upgrade;