diff --git a/.gitignore b/.gitignore index bcd82a863..4a1ba47fe 100644 --- a/.gitignore +++ b/.gitignore @@ -76,6 +76,9 @@ typescript # Github actions RELEASE_NOTES.md +# Test backups +api/dev/configs/api.json.backup + # Docker Deploy Folder deploy/* !deploy/.gitkeep diff --git a/api/.env.development b/api/.env.development index 22b9f174d..511bc0e67 100644 --- a/api/.env.development +++ b/api/.env.development @@ -17,6 +17,7 @@ PATHS_RCLONE_SOCKET=./dev/rclone-socket PATHS_LOG_BASE=./dev/log # Where we store logs PATHS_LOGS_FILE=./dev/log/graphql-api.log PATHS_CONNECT_STATUS_FILE_PATH=./dev/connectStatus.json # Connect plugin status file +PATHS_OIDC_JSON=./dev/configs/oidc.local.json ENVIRONMENT="development" NODE_ENV="development" PORT="3001" diff --git a/api/.gitignore b/api/.gitignore index 69e5493bd..1e587b405 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -86,3 +86,8 @@ deploy/* # local api configs - don't need project-wide tracking dev/connectStatus.json dev/configs/* +# local status - doesn't need to be tracked +dev/connectStatus.json + +# local OIDC config for testing - contains secrets +dev/configs/oidc.local.json diff --git a/api/dev/configs/README.md b/api/dev/configs/README.md new file mode 100644 index 000000000..4773d356e --- /dev/null +++ b/api/dev/configs/README.md @@ -0,0 +1,34 @@ +# Development Configuration Files + +This directory contains configuration files for local development. + +## OIDC Configuration + +### oidc.json +The default OIDC configuration file. This file is committed to git and should only contain non-sensitive test configurations. + +### Using a Local Configuration (gitignored) +For local testing with real OAuth providers: + +1. Create an `oidc.local.json` file based on `oidc.json` +2. Set the environment variable: `PATHS_OIDC_JSON=./dev/configs/oidc.local.json` +3. The API will load your local configuration instead of the default + +Example: +```bash +PATHS_OIDC_JSON=./dev/configs/oidc.local.json pnpm dev +``` + +### Setting up OAuth Apps + +#### Google +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project or select existing +3. Enable Google+ API +4. Create OAuth 2.0 credentials +5. Add authorized redirect URI: `http://localhost:3000/graphql/api/auth/oidc/callback` + +#### GitHub +1. Go to GitHub Settings > Developer settings > OAuth Apps +2. Create a new OAuth App +3. Set Authorization callback URL: `http://localhost:3000/graphql/api/auth/oidc/callback` \ No newline at end of file diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index 812a29f73..51fda8706 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -1,7 +1,9 @@ { - "version": "4.12.0", - "extraOrigins": [], - "sandbox": true, - "ssoSubIds": [], - "plugins": ["unraid-api-plugin-connect"] -} + "version": "4.12.0", + "extraOrigins": [], + "sandbox": true, + "ssoSubIds": [], + "plugins": [ + "unraid-api-plugin-connect" + ] +} \ No newline at end of file diff --git a/api/dev/configs/connect.json b/api/dev/configs/connect.json index aed9f170e..a853ba3f9 100644 --- a/api/dev/configs/connect.json +++ b/api/dev/configs/connect.json @@ -2,11 +2,11 @@ "wanaccess": true, "wanport": 8443, "upnpEnabled": false, - "apikey": "_______________________BIG_API_KEY_HERE_________________________", + "apikey": "", "localApiKey": "_______________________LOCAL_API_KEY_HERE_________________________", "email": "test@example.com", "username": "zspearmint", "avatar": "https://via.placeholder.com/200", "regWizTime": "1611175408732_0951-1653-3509-FBA155FA23C0", - "dynamicRemoteAccessType": "DISABLED" + "dynamicRemoteAccessType": "STATIC" } \ No newline at end of file diff --git a/api/dev/configs/oidc.json b/api/dev/configs/oidc.json new file mode 100644 index 000000000..c9317ecee --- /dev/null +++ b/api/dev/configs/oidc.json @@ -0,0 +1,21 @@ +{ + "providers": [ + { + "id": "unraid.net", + "name": "Unraid.net", + "clientId": "CONNECT_SERVER_SSO", + "issuer": "https://account.unraid.net", + "authorizationEndpoint": "https://account.unraid.net/sso/", + "tokenEndpoint": "https://account.unraid.net/api/oauth2/token", + "scopes": [ + "openid", + "profile", + "email" + ], + "authorizedSubIds": [ + "297294e2-b31c-4bcc-a441-88aee0ad609f" + ], + "buttonText": "Login With Unraid.net" + } + ] +} \ No newline at end of file diff --git a/api/docs/public/images/advanced-rules.png b/api/docs/public/images/advanced-rules.png new file mode 100644 index 000000000..dd60e6dc0 Binary files /dev/null and b/api/docs/public/images/advanced-rules.png differ diff --git a/api/docs/public/images/button-customization.png b/api/docs/public/images/button-customization.png new file mode 100644 index 000000000..516cd0c3c Binary files /dev/null and b/api/docs/public/images/button-customization.png differ diff --git a/api/docs/public/images/configured-provider.png b/api/docs/public/images/configured-provider.png new file mode 100644 index 000000000..c3026af92 Binary files /dev/null and b/api/docs/public/images/configured-provider.png differ diff --git a/api/docs/public/images/default-unraid-provider.png b/api/docs/public/images/default-unraid-provider.png new file mode 100644 index 000000000..8d1f3227d Binary files /dev/null and b/api/docs/public/images/default-unraid-provider.png differ diff --git a/api/docs/public/images/sso-with-options.png b/api/docs/public/images/sso-with-options.png new file mode 100644 index 000000000..2206d8b85 Binary files /dev/null and b/api/docs/public/images/sso-with-options.png differ diff --git a/api/docs/public/index.md b/api/docs/public/index.md index 35ac31144..28a0305e3 100644 --- a/api/docs/public/index.md +++ b/api/docs/public/index.md @@ -22,6 +22,7 @@ The API will be integrated directly into the Unraid operating system in an upcom - [CLI Commands](./cli.md) - Reference for all available command-line interface commands - [Using the Unraid API](./how-to-use-the-api.md) - Comprehensive guide on using the GraphQL API +- [OIDC Provider Setup](./oidc-provider-setup.md) - OIDC SSO provider configuration examples - [Upcoming Features](./upcoming-features.md) - Roadmap of planned features and improvements ## Key Features diff --git a/api/docs/public/oidc-provider-setup.md b/api/docs/public/oidc-provider-setup.md new file mode 100644 index 000000000..60de65df6 --- /dev/null +++ b/api/docs/public/oidc-provider-setup.md @@ -0,0 +1,402 @@ +--- +title: OIDC Provider Setup +description: Configure OIDC (OpenID Connect) providers for SSO authentication in Unraid API +sidebar_position: 3 +--- + +# OIDC Provider Setup + +This guide walks you through configuring OIDC (OpenID Connect) providers for SSO authentication in the Unraid API using the web interface. + +## Accessing OIDC Settings + +1. Navigate to your Unraid server's web interface +2. The OIDC Providers section is available on the main configuration page +3. You'll see tabs for different providers - click the **+** button to add a new provider + +### OIDC Providers Interface Overview + +![Login Page with SSO Options](./images/sso-with-options.png) +_Screenshot: Login page showing traditional login form with SSO options - "Login With Unraid.net" and "Sign in with Google" buttons_ + +The interface includes: + +- **Provider tabs**: Each configured provider (Unraid.net, Google, etc.) appears as a tab +- **Add Provider button**: Click the **+** button to add new providers +- **Authorization Mode dropdown**: Toggle between "simple" and "advanced" modes +- **Simple Authorization section**: Configure allowed email domains and specific addresses +- **Add Item buttons**: Click to add multiple authorization rules + +## Understanding Authorization Modes + +The interface provides two authorization modes: + +### Simple Mode (Recommended) + +Simple mode is the easiest way to configure authorization. You can: + +- Allow specific email domains (e.g., @company.com) +- Allow specific email addresses +- Configure who can access your Unraid server with minimal setup + +**When to use Simple Mode:** + +- You want to allow all users from your company domain +- You have a small list of specific users +- You're new to OIDC configuration + +
+Advanced Mode + +Advanced mode provides granular control using claim-based rules. You can: + +- Create complex authorization rules based on JWT claims +- Use operators like equals, contains, endsWith, startsWith +- Combine multiple conditions with OR/AND logic +- Choose whether ANY rule must pass (OR mode) or ALL rules must pass (AND mode) + +**When to use Advanced Mode:** + +- You need to check group memberships +- You want to verify multiple claims (e.g., email domain AND verified status) +- You have complex authorization requirements +- You need fine-grained control over how rules are evaluated + +
+ +## Authorization Rules + +![Authorization Rules Configuration](./images/advanced-rules.png) +_Screenshot: Advanced authorization rules showing JWT claim configuration with email endsWith operator for domain-based access control_ + +### Simple Mode Examples + +#### Allow Company Domain + +In Simple Authorization: + +- **Allowed Email Domains**: Enter `company.com` +- This allows anyone with @company.com email + +#### Allow Specific Users + +- **Specific Email Addresses**: Add individual emails +- Click **Add Item** to add multiple addresses + +
+Advanced Mode Examples + +#### Authorization Rule Mode + +When using multiple rules, you can choose how they're evaluated: + +- **OR Mode** (default): User is authorized if ANY rule passes +- **AND Mode**: User is authorized only if ALL rules pass + +#### Email Domain with Verification (AND Mode) + +To require both email domain AND verification: + +1. Set **Authorization Rule Mode** to `AND` +2. Add two rules: + - Rule 1: + - **Claim**: `email` + - **Operator**: `endsWith` + - **Value**: `@company.com` + - Rule 2: + - **Claim**: `email_verified` + - **Operator**: `equals` + - **Value**: `true` + +This ensures users must have both a company email AND a verified email address. + +#### Group-Based Access (OR Mode) + +To allow access to multiple groups: + +1. Set **Authorization Rule Mode** to `OR` (default) +2. Add rules for each group: + - **Claim**: `groups` + - **Operator**: `contains` + - **Value**: `admins` + + Or add another rule: + - **Claim**: `groups` + - **Operator**: `contains` + - **Value**: `developers` + +Users in either `admins` OR `developers` group will be authorized. + +#### Multiple Domains + +- **Claim**: `email` +- **Operator**: `endsWith` +- **Values**: Add multiple domains (e.g., `company.com`, `subsidiary.com`) + +#### Complex Authorization (AND Mode) + +For strict security requiring multiple conditions: + +1. Set **Authorization Rule Mode** to `AND` +2. Add multiple rules that ALL must pass: + - Email must be from company domain + - Email must be verified + - User must be in specific group + - Account must have 2FA enabled (if claim available) + +
+ +
+Configuration Interface Details + +### Provider Tabs + +- Each configured provider appears as a tab at the top +- Click a tab to switch between provider configurations +- The **+** button on the right adds a new provider + +### Authorization Mode Dropdown + +- **simple**: Best for email-based authorization (recommended for most users) +- **advanced**: For complex claim-based rules using JWT claims + +### Simple Authorization Fields + +When "simple" mode is selected, you'll see: + +- **Allowed Email Domains**: Enter domains without @ (e.g., `company.com`) + - Helper text: "Users with emails ending in these domains can login" +- **Specific Email Addresses**: Add individual email addresses + - Helper text: "Only these exact email addresses can login" +- **Add Item** buttons to add multiple entries + +### Advanced Authorization Fields + +When "advanced" mode is selected, you'll see: + +- **Authorization Rule Mode**: Choose `OR` (any rule passes) or `AND` (all rules must pass) +- **Authorization Rules**: Add multiple claim-based rules +- **For each rule**: + - **Claim**: The JWT claim to check + - **Operator**: How to compare (equals, contains, endsWith, startsWith) + - **Value**: What to match against + +### Additional Interface Elements + +- **Enable Developer Sandbox**: Toggle to enable GraphQL sandbox at `/graphql` +- The interface uses a dark theme for better visibility +- Field validation indicators help ensure correct configuration + +
+ +### Required Redirect URI + +All providers must be configured with this redirect URI: + +``` +http://YOUR_UNRAID_IP:3001/graphql/api/auth/oidc/callback +``` + +Replace `YOUR_UNRAID_IP` with your actual server IP address. + +### Issuer URL Format + +The **Issuer URL** field accepts both formats, but **base URL is strongly recommended** for security: + +- **Base URL** (recommended): `https://accounts.google.com` +- **Full discovery URL**: `https://accounts.google.com/.well-known/openid-configuration` + +**⚠️ Security Note**: Always use the base URL format when possible. The system automatically appends `/.well-known/openid-configuration` for OIDC discovery. Using the full discovery URL directly disables important issuer validation checks and is not recommended by the OpenID Connect specification. + +**Examples of correct base URLs:** +- Google: `https://accounts.google.com` +- Microsoft/Azure: `https://login.microsoftonline.com/YOUR_TENANT_ID/v2.0` +- Keycloak: `https://keycloak.example.com/realms/YOUR_REALM` +- Authelia: `https://auth.yourdomain.com` + +## Testing Your Configuration + +![Login Page with SSO Buttons](./images/sso-with-options.png) +_Screenshot: Unraid login page displaying both traditional username/password authentication and SSO options with customized provider buttons_ + +1. Save your provider configuration +2. Log out (if logged in) +3. Navigate to the login page +4. Your configured provider button should appear +5. Click to test the login flow + +## Troubleshooting + +### Common Issues + +#### "Provider not found" error + +- Ensure the Issuer URL is correct +- Check that the provider supports OIDC discovery (/.well-known/openid-configuration) + +#### "Authorization failed" + +- In Simple Mode: Check email domains are entered correctly (without @) +- In Advanced Mode: + - Verify claim names match exactly what your provider sends + - Check if Authorization Rule Mode is set correctly (OR vs AND) + - Ensure all required claims are present in the token +- Enable debug logging to see actual claims and rule evaluation + +#### "Invalid redirect URI" + +- Ensure the redirect URI in your provider matches exactly +- Include the port number (:3001) +- Use HTTP for local, HTTPS for production + +#### Cannot see login button + +- Check that at least one authorization rule is configured +- Verify the provider is enabled/saved + +### Debug Mode + +To troubleshoot issues: + +1. Enable debug logging: + +```bash +LOG_LEVEL=debug unraid-api start --debug +``` + +2. Check logs for: + +- Received claims from provider +- Authorization rule evaluation +- Token validation errors + +## Security Best Practices + +1. **Always use HTTPS in production** - OAuth requires secure connections +2. **Use Simple Mode for authorization** - Prevents overly accepting configurations and reduces misconfiguration risks +3. **Be specific with authorization** - Don't use overly broad rules +4. **Rotate secrets regularly** - Update client secrets periodically +5. **Test thoroughly** - Verify only intended users can access + +## Need Help? + +- Check provider's OIDC documentation +- Review Unraid API logs for detailed error messages +- Ensure your provider supports standard OIDC discovery +- Verify network connectivity between Unraid and provider + +## Provider-Specific Setup + +### Unraid.net Provider + +The Unraid.net provider is built-in and pre-configured. You only need to configure authorization rules in the interface. + +**Configuration:** +- **Issuer URL**: Pre-configured (built-in provider) +- **Client ID/Secret**: Pre-configured (built-in provider) +- **Redirect URI**: `http://YOUR_UNRAID_IP:3001/graphql/api/auth/oidc/callback` + +:::warning[Security Notice] +**Always use HTTPS for production redirect URIs!** The examples above use HTTP for initial setup and testing only. In production environments, you MUST use HTTPS (e.g., `https://YOUR_UNRAID_IP:3001/graphql/api/auth/oidc/callback`) to ensure secure communication and prevent credential interception. Most OIDC providers will reject HTTP redirect URIs for security reasons. +::: + +Configure authorization rules using Simple Mode (allowed email domains/addresses) or Advanced Mode for complex requirements. + +### Google + +Set up OAuth 2.0 credentials in [Google Cloud Console](https://console.cloud.google.com/): + +1. Go to **APIs & Services** → **Credentials** +2. Click **Create Credentials** → **OAuth client ID** +3. Choose **Web application** as the application type +4. Add your redirect URI to **Authorized redirect URIs** +5. Configure the OAuth consent screen if prompted + +**Configuration:** + +- **Issuer URL**: `https://accounts.google.com` +- **Client ID/Secret**: From your OAuth 2.0 client credentials +- **Required Scopes**: `openid`, `profile`, `email` +- **Redirect URI**: `http://YOUR_UNRAID_IP:3001/graphql/api/auth/oidc/callback` + +:::warning[Google Domain Requirements] +**Google requires valid domain names for OAuth redirect URIs.** Local IP addresses and `.local` domains are not accepted. To use Google OAuth with your Unraid server, you'll need: + +- **Option 1: Reverse Proxy** - Set up a reverse proxy (like NGINX Proxy Manager or Traefik) with a valid domain name pointing to your Unraid API +- **Option 2: Tailscale** - Use Tailscale to get a valid `*.ts.net` domain that Google will accept +- **Option 3: Dynamic DNS** - Use a DDNS service to get a public domain name for your server + +Remember to update your redirect URI in both Google Cloud Console and your Unraid OIDC configuration to use the valid domain. +::: + +For Google Workspace domains, use Advanced Mode with the `hd` claim to restrict access to your organization's domain. + +### Authelia + +Configure OIDC client in your Authelia `configuration.yml` with client ID `unraid-api` and generate a hashed secret using the Authelia hash-password command. + +**Configuration:** + +- **Issuer URL**: `https://auth.yourdomain.com` +- **Client ID**: `unraid-api` (or as configured in Authelia) +- **Client Secret**: Your unhashed secret +- **Required Scopes**: `openid`, `profile`, `email`, `groups` +- **Redirect URI**: `http://YOUR_UNRAID_IP:3001/graphql/api/auth/oidc/callback` + +Use Advanced Mode with `groups` claim for group-based authorization. + +### Microsoft/Azure AD + +Register a new app in [Azure Portal](https://portal.azure.com/) under Azure Active Directory → App registrations. Note the Application ID, create a client secret, and note your tenant ID. + +**Configuration:** + +- **Issuer URL**: `https://login.microsoftonline.com/YOUR_TENANT_ID/v2.0` +- **Client ID**: Your Application (client) ID +- **Client Secret**: Generated client secret +- **Required Scopes**: `openid`, `profile`, `email` +- **Redirect URI**: `http://YOUR_UNRAID_IP:3001/graphql/api/auth/oidc/callback` + +Authorization rules can be configured in the interface using email domains or advanced claims. + +### Keycloak + +Create a new confidential client in Keycloak Admin Console with `openid-connect` protocol and copy the client secret from the Credentials tab. + +**Configuration:** + +- **Issuer URL**: `https://keycloak.example.com/realms/YOUR_REALM` +- **Client ID**: `unraid-api` (or as configured in Keycloak) +- **Client Secret**: From Keycloak Credentials tab +- **Required Scopes**: `openid`, `profile`, `email` +- **Redirect URI**: `http://YOUR_UNRAID_IP:3001/graphql/api/auth/oidc/callback` + +For role-based authorization, use Advanced Mode with `realm_access.roles` or `resource_access` claims. + +### Authentik + +Create a new OAuth2/OpenID Provider in Authentik, then create an Application and link it to the provider. + +**Configuration:** + +- **Issuer URL**: `https://authentik.example.com/application/o/unraid-api/` +- **Client ID**: From Authentik provider configuration +- **Client Secret**: From Authentik provider configuration +- **Required Scopes**: `openid`, `profile`, `email` +- **Redirect URI**: `http://YOUR_UNRAID_IP:3001/graphql/api/auth/oidc/callback` + +Authorization rules can be configured in the interface. + +### Okta + +Create a new OIDC Web Application in Okta Admin Console and assign appropriate users or groups. + +**Configuration:** + +- **Issuer URL**: `https://YOUR_DOMAIN.okta.com` +- **Client ID**: From Okta application configuration +- **Client Secret**: From Okta application configuration +- **Required Scopes**: `openid`, `profile`, `email` +- **Redirect URI**: `http://YOUR_UNRAID_IP:3001/graphql/api/auth/oidc/callback` + +Authorization rules can be configured in the interface using email domains or advanced claims. diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 8a096ce51..85c4b5dad 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -1400,6 +1400,13 @@ type ApiConfig { plugins: [String!]! } +type SsoSettings implements Node { + id: PrefixedID! + + """List of configured OIDC providers""" + oidcProviders: [OidcProvider!]! +} + type UnifiedSettings implements Node { id: PrefixedID! @@ -1419,6 +1426,9 @@ type UpdateSettingsResponse { """The updated settings values""" values: JSON! + + """Warning messages about configuration issues found during validation""" + warnings: [String!] } type Settings implements Node { @@ -1427,10 +1437,115 @@ type Settings implements Node { """A view of all settings""" unified: UnifiedSettings! + """SSO settings""" + sso: SsoSettings! + """The API setting values""" api: ApiConfig! } +type OidcAuthorizationRule { + """The claim to check (e.g., email, sub, groups, hd)""" + claim: String! + + """The comparison operator""" + operator: AuthorizationOperator! + + """The value(s) to match against""" + value: [String!]! +} + +"""Operators for authorization rule matching""" +enum AuthorizationOperator { + EQUALS + CONTAINS + ENDS_WITH + STARTS_WITH +} + +type OidcProvider { + """The unique identifier for the OIDC provider""" + id: PrefixedID! + + """Display name of the OIDC provider""" + name: String! + + """OAuth2 client ID registered with the provider""" + clientId: String! + + """OAuth2 client secret (if required by provider)""" + clientSecret: String + + """ + OIDC issuer URL (e.g., https://accounts.google.com). Required for auto-discovery via /.well-known/openid-configuration + """ + issuer: String! + + """ + OAuth2 authorization endpoint URL. If omitted, will be auto-discovered from issuer/.well-known/openid-configuration + """ + authorizationEndpoint: String + + """ + OAuth2 token endpoint URL. If omitted, will be auto-discovered from issuer/.well-known/openid-configuration + """ + tokenEndpoint: String + + """ + JSON Web Key Set URI for token validation. If omitted, will be auto-discovered from issuer/.well-known/openid-configuration + """ + jwksUri: String + + """OAuth2 scopes to request (e.g., openid, profile, email)""" + scopes: [String!]! + + """Flexible authorization rules based on claims""" + authorizationRules: [OidcAuthorizationRule!] + + """ + Mode for evaluating authorization rules - OR (any rule passes) or AND (all rules must pass). Defaults to OR. + """ + authorizationRuleMode: AuthorizationRuleMode + + """Custom text for the login button""" + buttonText: String + + """URL or base64 encoded icon for the login button""" + buttonIcon: String + + """ + Button variant style from Reka UI. See https://reka-ui.com/docs/components/button + """ + buttonVariant: String + + """ + Custom CSS styles for the button (e.g., "background: linear-gradient(to right, #4f46e5, #7c3aed); border-radius: 9999px;") + """ + buttonStyle: String +} + +""" +Mode for evaluating authorization rules - OR (any rule passes) or AND (all rules must pass) +""" +enum AuthorizationRuleMode { + OR + AND +} + +type OidcSessionValidation { + valid: Boolean! + username: String +} + +type PublicOidcProvider { + id: ID! + name: String! + buttonText: String + buttonIcon: String + buttonVariant: String + buttonStyle: String +} + type UPSBattery { """ Battery charge level as a percentage (0-100). Unit: percent (%). Example: 100 means battery is fully charged @@ -1834,6 +1949,18 @@ type Query { rclone: RCloneBackupSettings! settings: Settings! isSSOEnabled: Boolean! + + """Get public OIDC provider information for login buttons""" + publicOidcProviders: [PublicOidcProvider!]! + + """Get all configured OIDC providers (admin only)""" + oidcProviders: [OidcProvider!]! + + """Get a specific OIDC provider by ID""" + oidcProvider(id: PrefixedID!): OidcProvider + + """Validate an OIDC session token (internal use for CLI validation)""" + validateOidcSession(token: String!): OidcSessionValidation! upsDevices: [UPSDevice!]! upsDeviceById(id: String!): UPSDevice upsConfiguration: UPSConfiguration! diff --git a/api/package.json b/api/package.json index 49110e6c9..8415bf995 100644 --- a/api/package.json +++ b/api/package.json @@ -15,7 +15,7 @@ "scripts": { "// Development": "", "start": "node dist/main.js", - "dev": "vite", + "dev": "clear && vite", "dev:debug": "NODE_OPTIONS='--inspect-brk=9229 --enable-source-maps' vite", "command": "COMMAND_TESTER=true pnpm run build > /dev/null 2>&1 && NODE_ENV=development ./dist/cli.js", "command:raw": "./dist/cli.js", @@ -125,6 +125,7 @@ "nestjs-pino": "4.4.0", "node-cache": "5.1.2", "node-window-polyfill": "1.0.4", + "openid-client": "^6.6.2", "p-retry": "6.2.1", "passport-custom": "1.1.1", "passport-http-header-strategy": "1.1.0", diff --git a/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts b/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts index a9c741536..3370fffa4 100644 --- a/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts +++ b/api/src/__test__/graphql/resolvers/rclone-api.service.test.ts @@ -34,6 +34,15 @@ vi.mock('@app/store/index.js', () => ({ }), }, })); +vi.mock('@app/environment.js', () => ({ + ENVIRONMENT: 'development', + environment: { + IS_MAIN_PROCESS: true, + }, +})); +vi.mock('@app/core/utils/files/file-exists.js', () => ({ + fileExists: vi.fn().mockResolvedValue(true), +})); // Mock NestJS Logger to suppress logs during tests vi.mock('@nestjs/common', async (importOriginal) => { @@ -63,13 +72,22 @@ describe('RCloneApiService', () => { const { execa } = await import('execa'); const pRetry = await import('p-retry'); const { existsSync } = await import('node:fs'); + const { fileExists } = await import('@app/core/utils/files/file-exists.js'); mockGot = vi.mocked(got); mockExeca = vi.mocked(execa); mockPRetry = vi.mocked(pRetry.default); mockExistsSync = vi.mocked(existsSync); - mockGot.post = vi.fn().mockResolvedValue({ body: {} }); + // Mock successful RClone API response for socket check + mockGot.post = vi.fn().mockResolvedValue({ body: { pid: 12345 } }); + + // Mock RClone binary exists check + vi.mocked(fileExists).mockResolvedValue(true); + + // Mock socket exists + mockExistsSync.mockReturnValue(true); + mockExeca.mockReturnValue({ on: vi.fn(), kill: vi.fn(), @@ -77,10 +95,12 @@ describe('RCloneApiService', () => { pid: 12345, } as any); mockPRetry.mockResolvedValue(undefined); - mockExistsSync.mockReturnValue(false); service = new RCloneApiService(); await service.onModuleInit(); + + // Reset the mock after initialization to prepare for test-specific responses + mockGot.post.mockClear(); }); describe('getProviders', () => { @@ -102,6 +122,9 @@ describe('RCloneApiService', () => { json: {}, responseType: 'json', enableUnixSockets: true, + headers: expect.objectContaining({ + Authorization: expect.stringMatching(/^Basic /), + }), }) ); }); @@ -129,6 +152,11 @@ describe('RCloneApiService', () => { 'http://unix:/tmp/rclone.sock:/config/listremotes', expect.objectContaining({ json: {}, + responseType: 'json', + enableUnixSockets: true, + headers: expect.objectContaining({ + Authorization: expect.stringMatching(/^Basic /), + }), }) ); }); @@ -155,6 +183,11 @@ describe('RCloneApiService', () => { 'http://unix:/tmp/rclone.sock:/config/get', expect.objectContaining({ json: { name: 'test-remote' }, + responseType: 'json', + enableUnixSockets: true, + headers: expect.objectContaining({ + Authorization: expect.stringMatching(/^Basic /), + }), }) ); }); @@ -193,6 +226,11 @@ describe('RCloneApiService', () => { type: 's3', parameters: { access_key_id: 'AKIA...', secret_access_key: 'secret' }, }, + responseType: 'json', + enableUnixSockets: true, + headers: expect.objectContaining({ + Authorization: expect.stringMatching(/^Basic /), + }), }) ); }); @@ -217,6 +255,11 @@ describe('RCloneApiService', () => { name: 'existing-remote', access_key_id: 'NEW_AKIA...', }, + responseType: 'json', + enableUnixSockets: true, + headers: expect.objectContaining({ + Authorization: expect.stringMatching(/^Basic /), + }), }) ); }); @@ -235,6 +278,11 @@ describe('RCloneApiService', () => { 'http://unix:/tmp/rclone.sock:/config/delete', expect.objectContaining({ json: { name: 'remote-to-delete' }, + responseType: 'json', + enableUnixSockets: true, + headers: expect.objectContaining({ + Authorization: expect.stringMatching(/^Basic /), + }), }) ); }); @@ -261,6 +309,11 @@ describe('RCloneApiService', () => { dstFs: 'remote:backup/path', delete_on: 'dst', }, + responseType: 'json', + enableUnixSockets: true, + headers: expect.objectContaining({ + Authorization: expect.stringMatching(/^Basic /), + }), }) ); }); @@ -279,6 +332,11 @@ describe('RCloneApiService', () => { 'http://unix:/tmp/rclone.sock:/job/status', expect.objectContaining({ json: { jobid: 'job-123' }, + responseType: 'json', + enableUnixSockets: true, + headers: expect.objectContaining({ + Authorization: expect.stringMatching(/^Basic /), + }), }) ); }); @@ -299,6 +357,11 @@ describe('RCloneApiService', () => { 'http://unix:/tmp/rclone.sock:/job/list', expect.objectContaining({ json: {}, + responseType: 'json', + enableUnixSockets: true, + headers: expect.objectContaining({ + Authorization: expect.stringMatching(/^Basic /), + }), }) ); }); diff --git a/api/src/__test__/setup.ts b/api/src/__test__/setup.ts index 00c9fbe05..4c6729b84 100644 --- a/api/src/__test__/setup.ts +++ b/api/src/__test__/setup.ts @@ -3,6 +3,7 @@ import '@app/__test__/setup/env-setup.js'; import '@app/__test__/setup/keyserver-mock.js'; import '@app/__test__/setup/config-setup.js'; import '@app/__test__/setup/store-reset.js'; +import '@app/__test__/setup/api-json-backup.js'; // This file is automatically loaded by Vitest before running tests // It imports all the setup files that need to be run before tests diff --git a/api/src/__test__/setup/api-json-backup.ts b/api/src/__test__/setup/api-json-backup.ts new file mode 100644 index 000000000..7b9bdd11b --- /dev/null +++ b/api/src/__test__/setup/api-json-backup.ts @@ -0,0 +1,36 @@ +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { join, resolve } from 'path'; + +import { afterAll, beforeAll } from 'vitest'; + +// Get the project root directory +const projectRoot = resolve(process.cwd()); +const apiJsonPath = join(projectRoot, 'dev/configs/api.json'); +const apiJsonBackupPath = join(projectRoot, 'dev/configs/api.json.backup'); + +let originalContent: string | null = null; + +/** + * Backs up api.json before tests run and restores it after tests complete. + * This prevents tests from permanently modifying the development configuration. + */ +export function setupApiJsonBackup() { + beforeAll(() => { + // Save the original content if the file exists + if (existsSync(apiJsonPath)) { + originalContent = readFileSync(apiJsonPath, 'utf-8'); + // Create a backup file as well for safety + writeFileSync(apiJsonBackupPath, originalContent, 'utf-8'); + } + }); + + afterAll(() => { + // Restore the original content if we saved it + if (originalContent !== null) { + writeFileSync(apiJsonPath, originalContent, 'utf-8'); + } + }); +} + +// Auto-run for all tests that import this module +setupApiJsonBackup(); diff --git a/api/src/unraid-api/app/app.module.ts b/api/src/unraid-api/app/app.module.ts index 9f7ebac1f..d472c78f0 100644 --- a/api/src/unraid-api/app/app.module.ts +++ b/api/src/unraid-api/app/app.module.ts @@ -28,18 +28,10 @@ import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/u logger: apiLogger, autoLogging: false, timestamp: false, - ...(LOG_LEVEL !== 'TRACE' - ? { - serializers: { - req: (req) => ({ - id: req.id, - method: req.method, - url: req.url, - remoteAddress: req.remoteAddress, - }), - }, - } - : {}), + serializers: { + req: () => undefined, + res: () => undefined, + }, }, }), AuthModule, diff --git a/api/src/unraid-api/cli/generated/gql.ts b/api/src/unraid-api/cli/generated/gql.ts index 7ab7b2e4f..1f89d183f 100644 --- a/api/src/unraid-api/cli/generated/gql.ts +++ b/api/src/unraid-api/cli/generated/gql.ts @@ -23,6 +23,7 @@ type Documents = { "\n query SystemReport {\n info {\n id\n machineId\n system {\n manufacturer\n model\n version\n sku\n serial\n uuid\n }\n versions {\n unraid\n kernel\n openssl\n }\n }\n config {\n id\n valid\n error\n }\n server {\n id\n name\n }\n }\n": typeof types.SystemReportDocument, "\n query ConnectStatus {\n connect {\n id\n dynamicRemoteAccess {\n enabledType\n runningType\n error\n }\n }\n }\n": typeof types.ConnectStatusDocument, "\n query Services {\n services {\n id\n name\n online\n uptime {\n timestamp\n }\n version\n }\n }\n": typeof types.ServicesDocument, + "\n query ValidateOidcSession($token: String!) {\n validateOidcSession(token: $token) {\n valid\n username\n }\n }\n": typeof types.ValidateOidcSessionDocument, }; const documents: Documents = { "\n mutation AddPlugin($input: PluginManagementInput!) {\n addPlugin(input: $input)\n }\n": types.AddPluginDocument, @@ -34,6 +35,7 @@ const documents: Documents = { "\n query SystemReport {\n info {\n id\n machineId\n system {\n manufacturer\n model\n version\n sku\n serial\n uuid\n }\n versions {\n unraid\n kernel\n openssl\n }\n }\n config {\n id\n valid\n error\n }\n server {\n id\n name\n }\n }\n": types.SystemReportDocument, "\n query ConnectStatus {\n connect {\n id\n dynamicRemoteAccess {\n enabledType\n runningType\n error\n }\n }\n }\n": types.ConnectStatusDocument, "\n query Services {\n services {\n id\n name\n online\n uptime {\n timestamp\n }\n version\n }\n }\n": types.ServicesDocument, + "\n query ValidateOidcSession($token: String!) {\n validateOidcSession(token: $token) {\n valid\n username\n }\n }\n": types.ValidateOidcSessionDocument, }; /** @@ -86,6 +88,10 @@ export function gql(source: "\n query ConnectStatus {\n connect {\n * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function gql(source: "\n query Services {\n services {\n id\n name\n online\n uptime {\n timestamp\n }\n version\n }\n }\n"): (typeof documents)["\n query Services {\n services {\n id\n name\n online\n uptime {\n timestamp\n }\n version\n }\n }\n"]; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql(source: "\n query ValidateOidcSession($token: String!) {\n validateOidcSession(token: $token) {\n valid\n username\n }\n }\n"): (typeof documents)["\n query ValidateOidcSession($token: String!) {\n validateOidcSession(token: $token) {\n valid\n username\n }\n }\n"]; export function gql(source: string) { return (documents as any)[source] ?? {}; diff --git a/api/src/unraid-api/cli/generated/graphql.ts b/api/src/unraid-api/cli/generated/graphql.ts index 2596de020..10f256da5 100644 --- a/api/src/unraid-api/cli/generated/graphql.ts +++ b/api/src/unraid-api/cli/generated/graphql.ts @@ -385,6 +385,20 @@ export enum AuthPossession { OWN_ANY = 'OWN_ANY' } +/** Operators for authorization rule matching */ +export enum AuthorizationOperator { + CONTAINS = 'CONTAINS', + ENDS_WITH = 'ENDS_WITH', + EQUALS = 'EQUALS', + STARTS_WITH = 'STARTS_WITH' +} + +/** Mode for evaluating authorization rules - OR (any rule passes) or AND (all rules must pass) */ +export enum AuthorizationRuleMode { + AND = 'AND', + OR = 'OR' +} + export type Baseboard = Node & { __typename?: 'Baseboard'; assetTag?: Maybe; @@ -940,21 +954,25 @@ export type Mutation = { configureUps: Scalars['Boolean']['output']; connectSignIn: Scalars['Boolean']['output']; connectSignOut: Scalars['Boolean']['output']; + createDockerFolder: ResolvedOrganizerV1; /** Creates a new notification record */ createNotification: Notification; /** Deletes all archived notifications on server. */ deleteArchivedNotifications: NotificationOverview; + deleteDockerEntries: ResolvedOrganizerV1; deleteNotification: NotificationOverview; docker: DockerMutations; enableDynamicRemoteAccess: Scalars['Boolean']['output']; /** Initiates a flash drive backup using a configured remote. */ initiateFlashBackup: FlashBackupStatus; + moveDockerEntriesToFolder: ResolvedOrganizerV1; parityCheck: ParityCheckMutations; rclone: RCloneMutations; /** Reads each notification to recompute & update the overview. */ recalculateOverview: NotificationOverview; /** Remove one or more plugins from the API. Returns false if restart was triggered automatically, true if manual restart is required. */ removePlugin: Scalars['Boolean']['output']; + setDockerFolderChildren: ResolvedOrganizerV1; setupRemoteAccess: Scalars['Boolean']['output']; unarchiveAll: NotificationOverview; unarchiveNotifications: NotificationOverview; @@ -996,11 +1014,23 @@ export type MutationConnectSignInArgs = { }; +export type MutationCreateDockerFolderArgs = { + childrenIds?: InputMaybe>; + name: Scalars['String']['input']; + parentId?: InputMaybe; +}; + + export type MutationCreateNotificationArgs = { input: NotificationData; }; +export type MutationDeleteDockerEntriesArgs = { + entryIds: Array; +}; + + export type MutationDeleteNotificationArgs = { id: Scalars['PrefixedID']['input']; type: NotificationType; @@ -1017,11 +1047,23 @@ export type MutationInitiateFlashBackupArgs = { }; +export type MutationMoveDockerEntriesToFolderArgs = { + destinationFolderId: Scalars['String']['input']; + sourceEntryIds: Array; +}; + + export type MutationRemovePluginArgs = { input: PluginManagementInput; }; +export type MutationSetDockerFolderChildrenArgs = { + childrenIds: Array; + folderId?: InputMaybe; +}; + + export type MutationSetupRemoteAccessArgs = { input: SetupRemoteAccessInput; }; @@ -1129,12 +1171,62 @@ export type NotificationsListArgs = { filter: NotificationFilter; }; +export type OidcAuthorizationRule = { + __typename?: 'OidcAuthorizationRule'; + /** The claim to check (e.g., email, sub, groups, hd) */ + claim: Scalars['String']['output']; + /** The comparison operator */ + operator: AuthorizationOperator; + /** The value(s) to match against */ + value: Array; +}; + +export type OidcProvider = { + __typename?: 'OidcProvider'; + /** OAuth2 authorization endpoint URL. If omitted, will be auto-discovered from issuer/.well-known/openid-configuration */ + authorizationEndpoint?: Maybe; + /** Mode for evaluating authorization rules - OR (any rule passes) or AND (all rules must pass). Defaults to OR. */ + authorizationRuleMode?: Maybe; + /** Flexible authorization rules based on claims */ + authorizationRules?: Maybe>; + /** URL or base64 encoded icon for the login button */ + buttonIcon?: Maybe; + /** Custom CSS styles for the button (e.g., "background: linear-gradient(to right, #4f46e5, #7c3aed); border-radius: 9999px;") */ + buttonStyle?: Maybe; + /** Custom text for the login button */ + buttonText?: Maybe; + /** Button variant style from Reka UI. See https://reka-ui.com/docs/components/button */ + buttonVariant?: Maybe; + /** OAuth2 client ID registered with the provider */ + clientId: Scalars['String']['output']; + /** OAuth2 client secret (if required by provider) */ + clientSecret?: Maybe; + /** The unique identifier for the OIDC provider */ + id: Scalars['PrefixedID']['output']; + /** OIDC issuer URL (e.g., https://accounts.google.com). Required for auto-discovery via /.well-known/openid-configuration */ + issuer: Scalars['String']['output']; + /** JSON Web Key Set URI for token validation. If omitted, will be auto-discovered from issuer/.well-known/openid-configuration */ + jwksUri?: Maybe; + /** Display name of the OIDC provider */ + name: Scalars['String']['output']; + /** OAuth2 scopes to request (e.g., openid, profile, email) */ + scopes: Array; + /** OAuth2 token endpoint URL. If omitted, will be auto-discovered from issuer/.well-known/openid-configuration */ + tokenEndpoint?: Maybe; +}; + +export type OidcSessionValidation = { + __typename?: 'OidcSessionValidation'; + username?: Maybe; + valid: Scalars['Boolean']['output']; +}; + export type OrganizerContainerResource = { __typename?: 'OrganizerContainerResource'; id: Scalars['String']['output']; meta?: Maybe; name: Scalars['String']['output']; - type: OrganizerResourceType; + type: Scalars['String']['output']; }; export type OrganizerResource = { @@ -1142,17 +1234,9 @@ export type OrganizerResource = { id: Scalars['String']['output']; meta?: Maybe; name: Scalars['String']['output']; - type: OrganizerResourceType; + type: Scalars['String']['output']; }; -/** The type of organizer resource */ -export enum OrganizerResourceType { - BOOKMARK = 'BOOKMARK', - CONTAINER = 'CONTAINER', - FILE = 'FILE', - VM = 'VM' -} - export type Os = Node & { __typename?: 'Os'; arch?: Maybe; @@ -1266,6 +1350,16 @@ export type ProfileModel = Node & { username: Scalars['String']['output']; }; +export type PublicOidcProvider = { + __typename?: 'PublicOidcProvider'; + buttonIcon?: Maybe; + buttonStyle?: Maybe; + buttonText?: Maybe; + buttonVariant?: Maybe; + id: Scalars['ID']['output']; + name: Scalars['String']['output']; +}; + export type PublicPartnerInfo = { __typename?: 'PublicPartnerInfo'; /** Indicates if a partner logo exists */ @@ -1303,11 +1397,17 @@ export type Query = { network: Network; /** Get all notifications */ notifications: Notifications; + /** Get a specific OIDC provider by ID */ + oidcProvider?: Maybe; + /** Get all configured OIDC providers (admin only) */ + oidcProviders: Array; online: Scalars['Boolean']['output']; owner: Owner; parityHistory: Array; /** List all installed plugins with their metadata */ plugins: Array; + /** Get public OIDC provider information for login buttons */ + publicOidcProviders: Array; publicPartnerInfo?: Maybe; publicTheme: Theme; rclone: RCloneBackupSettings; @@ -1321,6 +1421,8 @@ export type Query = { upsConfiguration: UpsConfiguration; upsDeviceById?: Maybe; upsDevices: Array; + /** Validate an OIDC session token (internal use for CLI validation) */ + validateOidcSession: OidcSessionValidation; vars: Vars; /** Get information about all VMs on the system */ vms: Vms; @@ -1344,10 +1446,20 @@ export type QueryLogFileArgs = { }; +export type QueryOidcProviderArgs = { + id: Scalars['PrefixedID']['input']; +}; + + export type QueryUpsDeviceByIdArgs = { id: Scalars['String']['input']; }; + +export type QueryValidateOidcSessionArgs = { + token: Scalars['String']['input']; +}; + export type RCloneBackupConfigForm = { __typename?: 'RCloneBackupConfigForm'; dataSchema: Scalars['JSON']['output']; @@ -1571,6 +1683,8 @@ export type Settings = Node & { /** The API setting values */ api: ApiConfig; id: Scalars['PrefixedID']['output']; + /** SSO settings */ + sso: SsoSettings; /** A view of all settings */ unified: UnifiedSettings; }; @@ -1619,6 +1733,13 @@ export type Share = Node & { used?: Maybe; }; +export type SsoSettings = Node & { + __typename?: 'SsoSettings'; + id: Scalars['PrefixedID']['output']; + /** List of configured OIDC providers */ + oidcProviders: Array; +}; + export type Subscription = { __typename?: 'Subscription'; arraySubscription: UnraidArray; @@ -1855,6 +1976,8 @@ export type UpdateSettingsResponse = { restartRequired: Scalars['Boolean']['output']; /** The updated settings values */ values: Scalars['JSON']['output']; + /** Warning messages about configuration issues found during validation */ + warnings?: Maybe>; }; export type Uptime = { @@ -2238,6 +2361,13 @@ export type ServicesQueryVariables = Exact<{ [key: string]: never; }>; export type ServicesQuery = { __typename?: 'Query', services: Array<{ __typename?: 'Service', id: any, name?: string | null, online?: boolean | null, version?: string | null, uptime?: { __typename?: 'Uptime', timestamp?: string | null } | null }> }; +export type ValidateOidcSessionQueryVariables = Exact<{ + token: Scalars['String']['input']; +}>; + + +export type ValidateOidcSessionQuery = { __typename?: 'Query', validateOidcSession: { __typename?: 'OidcSessionValidation', valid: boolean, username?: string | null } }; + export const AddPluginDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AddPlugin"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PluginManagementInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addPlugin"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; export const RemovePluginDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemovePlugin"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PluginManagementInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removePlugin"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; @@ -2247,4 +2377,5 @@ export const GetPluginsDocument = {"kind":"Document","definitions":[{"kind":"Ope export const GetSsoUsersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSSOUsers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"settings"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"api"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ssoSubIds"}}]}}]}}]}}]} as unknown as DocumentNode; export const SystemReportDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SystemReport"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"info"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"machineId"}},{"kind":"Field","name":{"kind":"Name","value":"system"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"manufacturer"}},{"kind":"Field","name":{"kind":"Name","value":"model"}},{"kind":"Field","name":{"kind":"Name","value":"version"}},{"kind":"Field","name":{"kind":"Name","value":"sku"}},{"kind":"Field","name":{"kind":"Name","value":"serial"}},{"kind":"Field","name":{"kind":"Name","value":"uuid"}}]}},{"kind":"Field","name":{"kind":"Name","value":"versions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unraid"}},{"kind":"Field","name":{"kind":"Name","value":"kernel"}},{"kind":"Field","name":{"kind":"Name","value":"openssl"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"config"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"server"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; export const ConnectStatusDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ConnectStatus"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connect"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dynamicRemoteAccess"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enabledType"}},{"kind":"Field","name":{"kind":"Name","value":"runningType"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]}}]} as unknown as DocumentNode; -export const ServicesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"online"}},{"kind":"Field","name":{"kind":"Name","value":"uptime"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"timestamp"}}]}},{"kind":"Field","name":{"kind":"Name","value":"version"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file +export const ServicesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"online"}},{"kind":"Field","name":{"kind":"Name","value":"uptime"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"timestamp"}}]}},{"kind":"Field","name":{"kind":"Name","value":"version"}}]}}]}}]} as unknown as DocumentNode; +export const ValidateOidcSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ValidateOidcSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"validateOidcSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/api/src/unraid-api/cli/queries/validate-oidc-session.query.ts b/api/src/unraid-api/cli/queries/validate-oidc-session.query.ts new file mode 100644 index 000000000..3a14ba214 --- /dev/null +++ b/api/src/unraid-api/cli/queries/validate-oidc-session.query.ts @@ -0,0 +1,10 @@ +import { gql } from '@app/unraid-api/cli/generated/index.js'; + +export const VALIDATE_OIDC_SESSION_QUERY = gql(` + query ValidateOidcSession($token: String!) { + validateOidcSession(token: $token) { + valid + username + } + } +`); diff --git a/api/src/unraid-api/cli/sso/validate-token.command.ts b/api/src/unraid-api/cli/sso/validate-token.command.ts index 32cc1826c..0440c4831 100644 --- a/api/src/unraid-api/cli/sso/validate-token.command.ts +++ b/api/src/unraid-api/cli/sso/validate-token.command.ts @@ -1,11 +1,8 @@ -import type { JWTPayload } from 'jose'; -import { createLocalJWKSet, createRemoteJWKSet, jwtVerify } from 'jose'; import { CommandRunner, SubCommand } from 'nest-commander'; -import { JWKS_LOCAL_PAYLOAD, JWKS_REMOTE_LINK } from '@app/consts.js'; import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js'; import { LogService } from '@app/unraid-api/cli/log.service.js'; -import { SSO_USERS_QUERY } from '@app/unraid-api/cli/queries/sso-users.query.js'; +import { VALIDATE_OIDC_SESSION_QUERY } from '@app/unraid-api/cli/queries/validate-oidc-session.query.js'; @SubCommand({ name: 'validate-token', @@ -14,15 +11,11 @@ import { SSO_USERS_QUERY } from '@app/unraid-api/cli/queries/sso-users.query.js' arguments: '', }) export class ValidateTokenCommand extends CommandRunner { - JWKSOffline: ReturnType; - JWKSOnline: ReturnType; constructor( private readonly logger: LogService, private readonly internalClient: CliInternalClientService ) { super(); - this.JWKSOffline = createLocalJWKSet(JWKS_LOCAL_PAYLOAD); - this.JWKSOnline = createRemoteJWKSet(new URL(JWKS_REMOTE_LINK)); } private createErrorAndExit = (errorMessage: string) => { @@ -46,68 +39,40 @@ export class ValidateTokenCommand extends CommandRunner { this.createErrorAndExit('Invalid token provided'); } - if (!/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/.test(token)) { - this.createErrorAndExit('Token format is invalid'); - } + // Always validate as OIDC token + await this.validateOidcToken(token); + } - let caughtError: null | unknown = null; - let tokenPayload: null | JWTPayload = null; + private async validateOidcToken(token: string): Promise { try { - // this.logger.debug('Attempting to validate token with local key'); - tokenPayload = (await jwtVerify(token, this.JWKSOffline)).payload; - } catch (error: unknown) { - try { - // this.logger.debug('Local validation failed for key, trying remote validation'); - tokenPayload = (await jwtVerify(token, this.JWKSOnline)).payload; - } catch (error: unknown) { - caughtError = error; - } - } - - if (caughtError) { - if (caughtError instanceof Error) { - this.createErrorAndExit(`Caught error validating jwt token: ${caughtError.message}`); - } else { - this.createErrorAndExit('Caught unknown error validating jwt token'); - } - } - - if (tokenPayload === null) { - this.createErrorAndExit('No data in JWT to use for user validation'); - } - - const username = tokenPayload?.sub; - - if (!username) { - return this.createErrorAndExit('No ID found in token'); - } - const client = await this.internalClient.getClient(); - - let result; - try { - result = await client.query({ - query: SSO_USERS_QUERY, + const client = await this.internalClient.getClient(); + const { data, errors } = await client.query({ + query: VALIDATE_OIDC_SESSION_QUERY, + variables: { token }, }); + + if (errors?.length) { + const errorMessages = errors.map((e) => e.message).join(', '); + this.createErrorAndExit(`GraphQL errors: ${errorMessages}`); + } + + const validation = data?.validateOidcSession; + + if (validation?.valid) { + this.logger.always( + JSON.stringify({ + error: null, + valid: true, + username: validation.username || 'root', + }) + ); + process.exit(0); + } else { + this.createErrorAndExit('Invalid OIDC session token'); + } } catch (error) { - this.createErrorAndExit('Failed to query SSO users'); - } - - if (result.errors && result.errors.length > 0) { - this.createErrorAndExit('Failed to retrieve SSO configuration'); - } - - const ssoUsers = result.data?.settings?.api?.ssoSubIds || []; - - if (ssoUsers.length === 0) { - this.createErrorAndExit( - 'No local user token set to compare to - please set any valid SSO IDs you would like to sign in with' - ); - } - if (ssoUsers.includes(username)) { - this.logger.always(JSON.stringify({ error: null, valid: true, username })); - process.exit(0); - } else { - this.createErrorAndExit('Username on token does not match'); + const errorMessage = error instanceof Error ? error.message : String(error); + this.createErrorAndExit(`Failed to validate OIDC session: ${errorMessage}`); } } } diff --git a/api/src/unraid-api/graph/graph.module.ts b/api/src/unraid-api/graph/graph.module.ts index 03217bfdd..554489aaf 100644 --- a/api/src/unraid-api/graph/graph.module.ts +++ b/api/src/unraid-api/graph/graph.module.ts @@ -12,6 +12,7 @@ import { NoUnusedVariablesRule } from 'graphql'; import { ENVIRONMENT } from '@app/environment.js'; import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js'; +import { createDynamicIntrospectionPlugin } from '@app/unraid-api/graph/introspection-plugin.js'; import { ResolversModule } from '@app/unraid-api/graph/resolvers/resolvers.module.js'; import { createSandboxPlugin } from '@app/unraid-api/graph/sandbox-plugin.js'; import { GlobalDepsModule } from '@app/unraid-api/plugin/global-deps.module.js'; @@ -34,7 +35,7 @@ import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js'; path: './generated-schema.graphql', } : true, - introspection: isSandboxEnabled(), + introspection: true, playground: false, // we handle this in the sandbox plugin context: async ({ req, connectionParams, extra }) => { return { @@ -43,7 +44,10 @@ import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js'; extra, }; }, - plugins: [createSandboxPlugin(isSandboxEnabled)] as any[], + plugins: [ + createDynamicIntrospectionPlugin(isSandboxEnabled), + createSandboxPlugin(), + ] as any[], subscriptions: { 'graphql-ws': { path: '/graphql', diff --git a/api/src/unraid-api/graph/introspection-plugin.spec.ts b/api/src/unraid-api/graph/introspection-plugin.spec.ts new file mode 100644 index 000000000..3def3549d --- /dev/null +++ b/api/src/unraid-api/graph/introspection-plugin.spec.ts @@ -0,0 +1,271 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createDynamicIntrospectionPlugin } from '@app/unraid-api/graph/introspection-plugin.js'; + +describe('Dynamic Introspection Plugin', () => { + const mockResponse = () => ({ + body: null as any, + http: { + status: 200, + }, + }); + + const runPlugin = async ( + query: string | undefined, + operationName: string | undefined, + sandboxEnabled: boolean + ) => { + const isSandboxEnabled = vi.fn().mockReturnValue(sandboxEnabled); + const plugin = createDynamicIntrospectionPlugin(isSandboxEnabled); + + const response = mockResponse(); + const requestContext = { + request: { + query, + operationName, + }, + response, + } as any; + + const requestListener = await (plugin as any).requestDidStart(); + await requestListener.willSendResponse(requestContext); + + return response; + }; + + describe('when sandbox is enabled', () => { + it('should allow introspection query with IntrospectionQuery operation name', async () => { + const response = await runPlugin( + 'query IntrospectionQuery { __schema { queryType { name } } }', + 'IntrospectionQuery', + true + ); + + expect(response.http.status).toBe(200); + expect(response.body).toBeNull(); + }); + + it('should allow direct __schema query', async () => { + const response = await runPlugin('{ __schema { queryType { name } } }', undefined, true); + + expect(response.http.status).toBe(200); + expect(response.body).toBeNull(); + }); + + it('should allow regular queries with __type field', async () => { + const response = await runPlugin( + 'query GetType { __type(name: "User") { name fields { name } } }', + 'GetType', + true + ); + + expect(response.http.status).toBe(200); + expect(response.body).toBeNull(); + }); + }); + + describe('when sandbox is disabled', () => { + it('should block introspection query with IntrospectionQuery operation name', async () => { + const response = await runPlugin( + 'query IntrospectionQuery { __schema { queryType { name } } }', + 'IntrospectionQuery', + false + ); + + expect(response.http.status).toBe(400); + expect(response.body).toEqual({ + kind: 'single', + singleResult: { + errors: [ + { + message: + 'GraphQL introspection is not allowed, but the current request is for introspection.', + extensions: { + code: 'INTROSPECTION_DISABLED', + }, + }, + ], + }, + }); + }); + + it('should block direct __schema query', async () => { + const response = await runPlugin('{ __schema { queryType { name } } }', undefined, false); + + expect(response.http.status).toBe(400); + expect(response.body?.singleResult?.errors?.[0]?.extensions?.code).toBe( + 'INTROSPECTION_DISABLED' + ); + }); + + it('should block __schema query with whitespace variations', async () => { + const queries = [ + '{__schema{queryType{name}}}', + '{ __schema { queryType { name } } }', + '{\n __schema\n {\n queryType\n {\n name\n }\n }\n}', + 'query { __schema { types { name } } }', + 'query MyQuery { __schema { directives { name } } }', + ]; + + for (const query of queries) { + const response = await runPlugin(query, undefined, false); + expect(response.http.status).toBe(400); + expect(response.body?.singleResult?.errors?.[0]?.extensions?.code).toBe( + 'INTROSPECTION_DISABLED' + ); + } + }); + + it('should allow regular queries without introspection', async () => { + const response = await runPlugin( + 'query GetUser { user(id: "123") { name email } }', + 'GetUser', + false + ); + + expect(response.http.status).toBe(200); + expect(response.body).toBeNull(); + }); + + it('should allow queries with __type field (not full introspection)', async () => { + const response = await runPlugin( + 'query GetType { __type(name: "User") { name fields { name } } }', + 'GetType', + false + ); + + expect(response.http.status).toBe(200); + expect(response.body).toBeNull(); + }); + + it('should allow queries with __typename field', async () => { + const response = await runPlugin( + 'query GetUser { user(id: "123") { __typename name email } }', + 'GetUser', + false + ); + + expect(response.http.status).toBe(200); + expect(response.body).toBeNull(); + }); + + it('should allow mutations', async () => { + const response = await runPlugin( + 'mutation CreateUser($input: UserInput!) { createUser(input: $input) { id name } }', + 'CreateUser', + false + ); + + expect(response.http.status).toBe(200); + expect(response.body).toBeNull(); + }); + + it('should allow subscriptions', async () => { + const response = await runPlugin( + 'subscription OnUserCreated { userCreated { id name } }', + 'OnUserCreated', + false + ); + + expect(response.http.status).toBe(200); + expect(response.body).toBeNull(); + }); + + it('should not block when __schema appears in a string or comment', async () => { + const response = await runPlugin( + 'query GetUser { user(id: "123") { name description } } # __schema is mentioned here', + 'GetUser', + false + ); + + expect(response.http.status).toBe(200); + expect(response.body).toBeNull(); + }); + + it('should handle missing query gracefully', async () => { + const response = await runPlugin(undefined, undefined, false); + + expect(response.http.status).toBe(200); + expect(response.body).toBeNull(); + }); + + it('should handle empty query gracefully', async () => { + const response = await runPlugin('', undefined, false); + + expect(response.http.status).toBe(200); + expect(response.body).toBeNull(); + }); + }); + + describe('edge cases', () => { + it('should handle response without http property', async () => { + const isSandboxEnabled = vi.fn().mockReturnValue(false); + const plugin = createDynamicIntrospectionPlugin(isSandboxEnabled); + + const response = { body: null as any }; + const requestContext = { + request: { + query: '{ __schema { queryType { name } } }', + operationName: undefined, + }, + response, + } as any; + + const requestListener = await (plugin as any).requestDidStart(); + await requestListener.willSendResponse(requestContext); + + expect(response.body).toEqual({ + kind: 'single', + singleResult: { + errors: [ + { + message: + 'GraphQL introspection is not allowed, but the current request is for introspection.', + extensions: { + code: 'INTROSPECTION_DISABLED', + }, + }, + ], + }, + }); + // Should not throw even though response.http doesn't exist + }); + + it('should check sandbox status dynamically on each request', async () => { + const isSandboxEnabled = vi.fn(); + const plugin = createDynamicIntrospectionPlugin(isSandboxEnabled); + + // First request - sandbox disabled + isSandboxEnabled.mockReturnValue(false); + const response1 = mockResponse(); + const requestContext1 = { + request: { + query: '{ __schema { queryType { name } } }', + operationName: undefined, + }, + response: response1, + } as any; + + let requestListener = await (plugin as any).requestDidStart(); + await requestListener.willSendResponse(requestContext1); + expect(response1.http.status).toBe(400); + + // Second request - sandbox enabled + isSandboxEnabled.mockReturnValue(true); + const response2 = mockResponse(); + const requestContext2 = { + request: { + query: '{ __schema { queryType { name } } }', + operationName: undefined, + }, + response: response2, + } as any; + + requestListener = await (plugin as any).requestDidStart(); + await requestListener.willSendResponse(requestContext2); + expect(response2.http.status).toBe(200); + + expect(isSandboxEnabled).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/api/src/unraid-api/graph/introspection-plugin.ts b/api/src/unraid-api/graph/introspection-plugin.ts new file mode 100644 index 000000000..c178fc858 --- /dev/null +++ b/api/src/unraid-api/graph/introspection-plugin.ts @@ -0,0 +1,43 @@ +import type { ApolloServerPlugin, GraphQLRequestListener } from '@apollo/server'; + +export const createDynamicIntrospectionPlugin = ( + isSandboxEnabled: () => boolean +): ApolloServerPlugin => ({ + requestDidStart: async () => + ({ + willSendResponse: async (requestContext) => { + const { request, response } = requestContext; + + // Detect introspection queries: + // 1. Standard operation name "IntrospectionQuery" + // 2. Queries containing __schema at root level (main introspection entry point) + // Note: __type and __typename are also used in regular queries, so we don't block them + const isIntrospectionRequest = + request.operationName === 'IntrospectionQuery' || + (request.query && + // Check for __schema which is the main introspection entry point + // Match patterns like: { __schema { ... } } or query { __schema { ... } } + /\{\s*__schema\s*[{(]/.test(request.query)); + + if (isIntrospectionRequest && !isSandboxEnabled()) { + response.body = { + kind: 'single', + singleResult: { + errors: [ + { + message: + 'GraphQL introspection is not allowed, but the current request is for introspection.', + extensions: { + code: 'INTROSPECTION_DISABLED', + }, + }, + ], + }, + }; + if (response.http) { + response.http.status = 400; + } + } + }, + }) satisfies GraphQLRequestListener, +}); diff --git a/api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts b/api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts index 550feb8f1..89fd362e9 100644 --- a/api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts +++ b/api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts @@ -10,13 +10,13 @@ import pRetry from 'p-retry'; import { sanitizeParams } from '@app/core/log.js'; import { fileExists } from '@app/core/utils/files/file-exists.js'; +import { ENVIRONMENT } from '@app/environment.js'; import { CreateRCloneRemoteDto, DeleteRCloneRemoteDto, GetRCloneJobStatusDto, GetRCloneRemoteConfigDto, GetRCloneRemoteDetailsDto, - RCloneProviderOptionResponse, RCloneProviderResponse, RCloneRemoteConfig, RCloneStartBackupInput, @@ -45,6 +45,13 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy { } async onModuleInit(): Promise { + // RClone startup disabled - early return + if (ENVIRONMENT === 'production') { + this.logger.debug('RClone startup is disabled'); + this.isInitialized = false; + return; + } + try { // Check if rclone binary is available first const isBinaryAvailable = await this.checkRcloneBinaryExists(); @@ -356,7 +363,9 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy { * Generic method to call the RClone RC API */ private async callRcloneApi(endpoint: string, params: Record = {}): Promise { - const url = `${this.rcloneBaseUrl}/${endpoint}`; + // Ensure endpoint starts with '/' for proper Unix socket URL format + const normalizedEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; + const url = `${this.rcloneBaseUrl}${normalizedEndpoint}`; try { this.logger.debug( `Calling RClone API: ${url} with params: ${JSON.stringify(sanitizeParams(params))}` diff --git a/api/src/unraid-api/graph/resolvers/resolvers.module.ts b/api/src/unraid-api/graph/resolvers/resolvers.module.ts index 49bb543df..e29746ba5 100644 --- a/api/src/unraid-api/graph/resolvers/resolvers.module.ts +++ b/api/src/unraid-api/graph/resolvers/resolvers.module.ts @@ -27,6 +27,7 @@ import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.modu import { RegistrationResolver } from '@app/unraid-api/graph/resolvers/registration/registration.resolver.js'; import { ServerResolver } from '@app/unraid-api/graph/resolvers/servers/server.resolver.js'; import { SettingsModule } from '@app/unraid-api/graph/resolvers/settings/settings.module.js'; +import { SsoModule } from '@app/unraid-api/graph/resolvers/sso/sso.module.js'; import { UPSModule } from '@app/unraid-api/graph/resolvers/ups/ups.module.js'; import { VarsResolver } from '@app/unraid-api/graph/resolvers/vars/vars.resolver.js'; import { VmMutationsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.mutations.resolver.js'; @@ -47,6 +48,7 @@ import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js'; FlashBackupModule, RCloneModule, SettingsModule, + SsoModule, UPSModule, ], providers: [ diff --git a/api/src/unraid-api/graph/resolvers/settings/settings.model.ts b/api/src/unraid-api/graph/resolvers/settings/settings.model.ts index 922ce0970..7e71e78b7 100644 --- a/api/src/unraid-api/graph/resolvers/settings/settings.model.ts +++ b/api/src/unraid-api/graph/resolvers/settings/settings.model.ts @@ -4,6 +4,8 @@ import { Node } from '@unraid/shared/graphql.model.js'; import { IsObject, ValidateNested } from 'class-validator'; import { GraphQLJSON } from 'graphql-scalars'; +import { SsoSettings } from '@app/unraid-api/graph/resolvers/settings/sso-settings.model.js'; + @ObjectType({ implements: () => Node, }) @@ -30,6 +32,12 @@ export class UpdateSettingsResponse { @Field(() => GraphQLJSON, { description: 'The updated settings values' }) values!: Record; + + @Field(() => [String], { + nullable: true, + description: 'Warning messages about configuration issues found during validation', + }) + warnings?: string[]; } @ObjectType({ @@ -39,4 +47,8 @@ export class Settings extends Node { @Field(() => UnifiedSettings, { description: 'A view of all settings' }) @ValidateNested() unified!: UnifiedSettings; + + @Field(() => SsoSettings, { description: 'SSO settings' }) + @ValidateNested() + sso!: SsoSettings; } diff --git a/api/src/unraid-api/graph/resolvers/settings/settings.module.ts b/api/src/unraid-api/graph/resolvers/settings/settings.module.ts index 5682657dd..795c5b7ac 100644 --- a/api/src/unraid-api/graph/resolvers/settings/settings.module.ts +++ b/api/src/unraid-api/graph/resolvers/settings/settings.module.ts @@ -5,14 +5,28 @@ import { UserSettingsModule } from '@unraid/shared/services/user-settings.js'; import { SsoUserService } from '@app/unraid-api/auth/sso-user.service.js'; import { SettingsResolver, + SsoSettingsResolver, UnifiedSettingsResolver, } from '@app/unraid-api/graph/resolvers/settings/settings.resolver.js'; import { ApiSettings } from '@app/unraid-api/graph/resolvers/settings/settings.service.js'; +import { SsoModule } from '@app/unraid-api/graph/resolvers/sso/sso.module.js'; import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.module.js'; @Module({ - imports: [UserSettingsModule, UnraidFileModifierModule], - providers: [SettingsResolver, UnifiedSettingsResolver, SsoUserService, ApiSettings], - exports: [SettingsResolver, UnifiedSettingsResolver, UserSettingsModule, ApiSettings], + imports: [UserSettingsModule, UnraidFileModifierModule, SsoModule], + providers: [ + SettingsResolver, + UnifiedSettingsResolver, + SsoSettingsResolver, + SsoUserService, + ApiSettings, + ], + exports: [ + SettingsResolver, + UnifiedSettingsResolver, + SsoSettingsResolver, + UserSettingsModule, + ApiSettings, + ], }) export class SettingsModule {} diff --git a/api/src/unraid-api/graph/resolvers/settings/settings.resolver.ts b/api/src/unraid-api/graph/resolvers/settings/settings.resolver.ts index 1ab1f5201..5db104cb1 100644 --- a/api/src/unraid-api/graph/resolvers/settings/settings.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/settings/settings.resolver.ts @@ -21,12 +21,16 @@ import { UpdateSettingsResponse, } from '@app/unraid-api/graph/resolvers/settings/settings.model.js'; import { ApiSettings } from '@app/unraid-api/graph/resolvers/settings/settings.service.js'; +import { SsoSettings } from '@app/unraid-api/graph/resolvers/settings/sso-settings.model.js'; +import { OidcConfigPersistence } from '@app/unraid-api/graph/resolvers/sso/oidc-config.service.js'; +import { OidcProvider } from '@app/unraid-api/graph/resolvers/sso/oidc-provider.model.js'; @Resolver(() => Settings) export class SettingsResolver { constructor( private readonly apiSettings: ApiSettings, - private readonly ssoUserService: SsoUserService + private readonly ssoUserService: SsoUserService, + private readonly oidcConfig: OidcConfigPersistence ) {} @Query(() => Settings) @@ -51,6 +55,13 @@ export class SettingsResolver { }; } + @ResolveField(() => SsoSettings) + async sso() { + return { + id: 'sso-settings', + }; + } + @Query(() => Boolean) @Public() public async isSSOEnabled(): Promise { @@ -68,7 +79,7 @@ export class UnifiedSettingsResolver { @ResolveField(() => GraphQLJSON) async dataSchema() { - const { properties } = await this.userSettings.getAllSettings(['api']); + const { properties } = await this.userSettings.getAllSettings(['api', 'sso']); return { type: 'object', properties, @@ -77,7 +88,7 @@ export class UnifiedSettingsResolver { @ResolveField(() => GraphQLJSON) async uiSchema() { - const { elements } = await this.userSettings.getAllSettings(['api']); + const { elements } = await this.userSettings.getAllSettings(['api', 'sso']); return { type: 'VerticalLayout', elements, @@ -96,7 +107,7 @@ export class UnifiedSettingsResolver { possession: AuthPossession.ANY, }) async updateSettings( - @Args('input', { type: () => GraphQLJSON }) input: object + @Args('input', { type: () => GraphQLJSON }) input: Record ): Promise { this.logger.verbose('Updating Settings %O', input); const { restartRequired, values } = await this.userSettings.updateNamespacedValues(input); @@ -108,3 +119,13 @@ export class UnifiedSettingsResolver { return { restartRequired, values }; } } + +@Resolver(() => SsoSettings) +export class SsoSettingsResolver { + constructor(private readonly oidcConfig: OidcConfigPersistence) {} + + @ResolveField(() => [OidcProvider], { description: 'List of configured OIDC providers' }) + async oidcProviders(): Promise { + return this.oidcConfig.getProviders(); + } +} diff --git a/api/src/unraid-api/graph/resolvers/settings/settings.service.ts b/api/src/unraid-api/graph/resolvers/settings/settings.service.ts index 7b5365aff..0bc2850a2 100644 --- a/api/src/unraid-api/graph/resolvers/settings/settings.service.ts +++ b/api/src/unraid-api/graph/resolvers/settings/settings.service.ts @@ -8,6 +8,7 @@ import { UserSettingsService } from '@unraid/shared/services/user-settings.js'; import { execa } from 'execa'; import { SsoUserService } from '@app/unraid-api/auth/sso-user.service.js'; +import { OidcConfigPersistence } from '@app/unraid-api/graph/resolvers/sso/oidc-config.service.js'; import { createLabeledControl } from '@app/unraid-api/graph/utils/form-utils.js'; import { SettingSlice } from '@app/unraid-api/types/json-forms.js'; @@ -17,7 +18,8 @@ export class ApiSettings { constructor( private readonly userSettings: UserSettingsService, private readonly configService: ConfigService<{ api: ApiConfig }, true>, - private readonly ssoUserService: SsoUserService + private readonly ssoUserService: SsoUserService, + private readonly oidcConfig: OidcConfigPersistence ) { this.userSettings.register('api', { buildSlice: async () => this.buildSlice(), @@ -26,6 +28,19 @@ export class ApiSettings { }); } + private async shouldShowSsoUsersSettings(): Promise { + // Check if OIDC config exists, which means migration has happened + try { + const { access } = await import('fs/promises'); + await access(this.oidcConfig.configPath()); + // File exists, migration has happened + return false; + } catch { + // File doesn't exist, show the setting + return true; + } + } + getSettings(): ApiConfig { return { version: this.configService.get('api.version', { infer: true }), @@ -37,16 +52,13 @@ export class ApiSettings { } async updateSettings(settings: Partial) { - let restartRequired = false; + const restartRequired = false; if (typeof settings.sandbox === 'boolean') { - const currentSandbox = this.configService.get('api.sandbox', { infer: true }); - restartRequired ||= settings.sandbox !== currentSandbox; // @ts-expect-error - depend on the configService.get calls above for type safety this.configService.set('api.sandbox', settings.sandbox); } if (settings.ssoSubIds) { - const ssoNeedsRestart = await this.ssoUserService.setSsoUsers(settings.ssoSubIds); - restartRequired ||= ssoNeedsRestart; + await this.ssoUserService.setSsoUsers(settings.ssoSubIds); } if (settings.extraOrigins) { // @ts-expect-error - this is correct, but the configService typescript implementation is too narrow @@ -55,17 +67,19 @@ export class ApiSettings { return { restartRequired, values: await this.getSettings() }; } - buildSlice(): SettingSlice { - return mergeSettingSlices( - [ - this.sandboxSlice(), - this.ssoUsersSlice(), - // Because CORS is effectively disabled, this setting is no longer necessary - // keeping it here for in case it needs to be re-enabled - // this.extraOriginsSlice(), - ], - { as: 'api' } - ); + async buildSlice(): Promise { + const slices: SettingSlice[] = [this.sandboxSlice()]; + + // Only show SSO users setting if migration hasn't happened yet + if (await this.shouldShowSsoUsersSettings()) { + slices.push(this.ssoUsersSlice()); + } + + // Because CORS is effectively disabled, this setting is no longer necessary + // keeping it here for in case it needs to be re-enabled + // slices.push(this.extraOriginsSlice()); + + return mergeSettingSlices(slices, { as: 'api' }); } /** @@ -98,7 +112,6 @@ export class ApiSettings { /** * Extra origins settings slice - */ private extraOriginsSlice(): SettingSlice { return { properties: { @@ -126,7 +139,7 @@ export class ApiSettings { }), ], }; - } + } */ /** * SSO users settings slice @@ -140,14 +153,14 @@ export class ApiSettings { type: 'string', }, title: 'Unraid API SSO Users', - description: `Provide a list of Unique Unraid Account ID's. Find yours at account.unraid.net/settings. Requires restart if adding first user.`, + description: `Provide a list of Unique Unraid Account ID's. Find yours at account.unraid.net/settings.`, }, }, elements: [ createLabeledControl({ scope: '#/properties/api/properties/ssoSubIds', label: 'Unraid Connect SSO Users:', - description: `Provide a list of Unique Unraid Account IDs. Find yours at account.unraid.net/settings. Requires restart if adding first user.`, + description: `Provide a list of Unique Unraid Account IDs. Find yours at account.unraid.net/settings.`, controlOptions: { inputType: 'text', placeholder: 'UUID', diff --git a/api/src/unraid-api/graph/resolvers/settings/sso-settings.model.ts b/api/src/unraid-api/graph/resolvers/settings/sso-settings.model.ts new file mode 100644 index 000000000..30274fcc5 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/settings/sso-settings.model.ts @@ -0,0 +1,10 @@ +import { ObjectType } from '@nestjs/graphql'; + +import { Node } from '@unraid/shared/graphql.model.js'; + +@ObjectType({ + implements: () => Node, +}) +export class SsoSettings extends Node { + // oidcProviders field is resolved via SsoSettingsResolver +} diff --git a/api/src/unraid-api/graph/resolvers/sso/oidc-auth.service.test.ts b/api/src/unraid-api/graph/resolvers/sso/oidc-auth.service.test.ts new file mode 100644 index 000000000..8039438d9 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/sso/oidc-auth.service.test.ts @@ -0,0 +1,1542 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; + +import * as client from 'openid-client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OidcAuthService } from '@app/unraid-api/graph/resolvers/sso/oidc-auth.service.js'; +import { OidcConfigPersistence } from '@app/unraid-api/graph/resolvers/sso/oidc-config.service.js'; +import { + AuthorizationOperator, + AuthorizationRuleMode, + OidcAuthorizationRule, + OidcProvider, +} from '@app/unraid-api/graph/resolvers/sso/oidc-provider.model.js'; +import { OidcSessionService } from '@app/unraid-api/graph/resolvers/sso/oidc-session.service.js'; +import { OidcStateService } from '@app/unraid-api/graph/resolvers/sso/oidc-state.service.js'; +import { OidcValidationService } from '@app/unraid-api/graph/resolvers/sso/oidc-validation.service.js'; + +describe('OidcAuthService', () => { + let service: OidcAuthService; + let oidcConfig: any; + let sessionService: any; + let configService: any; + let stateService: any; + let validationService: any; + let module: TestingModule; + + beforeEach(async () => { + module = await Test.createTestingModule({ + providers: [ + OidcAuthService, + { + provide: ConfigService, + useValue: { + get: vi.fn(), + }, + }, + { + provide: OidcConfigPersistence, + useValue: { + getProvider: vi.fn(), + }, + }, + { + provide: OidcSessionService, + useValue: { + createSession: vi.fn(), + }, + }, + OidcStateService, + { + provide: OidcValidationService, + useValue: { + validateProvider: vi.fn(), + performDiscovery: vi.fn(), + }, + }, + ], + }).compile(); + + service = module.get(OidcAuthService); + oidcConfig = module.get(OidcConfigPersistence); + sessionService = module.get(OidcSessionService); + configService = module.get(ConfigService); + stateService = module.get(OidcStateService); + validationService = module.get(OidcValidationService); + }); + + describe('Authorization Rule Evaluation', () => { + // Access the private method through any type casting for testing + const evaluateRule = (rule: OidcAuthorizationRule, claims: any): boolean => { + return (service as any).evaluateRule(rule, claims); + }; + + describe('EQUALS operator', () => { + it('should return true when claim equals any value in the array', () => { + const rule: OidcAuthorizationRule = { + claim: 'email', + operator: AuthorizationOperator.EQUALS, + value: ['user@example.com', 'admin@example.com'], + }; + + expect(evaluateRule(rule, { email: 'user@example.com' })).toBe(true); + expect(evaluateRule(rule, { email: 'admin@example.com' })).toBe(true); + }); + + it('should return false when claim does not equal any value', () => { + const rule: OidcAuthorizationRule = { + claim: 'email', + operator: AuthorizationOperator.EQUALS, + value: ['user@example.com'], + }; + + expect(evaluateRule(rule, { email: 'other@example.com' })).toBe(false); + }); + + it('should handle numeric values correctly', () => { + const rule: OidcAuthorizationRule = { + claim: 'user_id', + operator: AuthorizationOperator.EQUALS, + value: ['12345'], + }; + + expect(evaluateRule(rule, { user_id: 12345 })).toBe(true); + expect(evaluateRule(rule, { user_id: '12345' })).toBe(true); + }); + }); + + describe('CONTAINS operator', () => { + it('should return true when claim contains any substring', () => { + const rule: OidcAuthorizationRule = { + claim: 'email', + operator: AuthorizationOperator.CONTAINS, + value: ['@company.com', '@partner.org'], + }; + + expect(evaluateRule(rule, { email: 'john@company.com' })).toBe(true); + expect(evaluateRule(rule, { email: 'jane@partner.org' })).toBe(true); + }); + + it('should return false when claim does not contain any substring', () => { + const rule: OidcAuthorizationRule = { + claim: 'email', + operator: AuthorizationOperator.CONTAINS, + value: ['@company.com'], + }; + + expect(evaluateRule(rule, { email: 'user@other.org' })).toBe(false); + }); + + it('should be case sensitive', () => { + const rule: OidcAuthorizationRule = { + claim: 'name', + operator: AuthorizationOperator.CONTAINS, + value: ['Admin'], + }; + + expect(evaluateRule(rule, { name: 'Administrator' })).toBe(true); + expect(evaluateRule(rule, { name: 'administrator' })).toBe(false); + }); + }); + + describe('STARTS_WITH operator', () => { + it('should return true when claim starts with any prefix', () => { + const rule: OidcAuthorizationRule = { + claim: 'department', + operator: AuthorizationOperator.STARTS_WITH, + value: ['eng-', 'dev-'], + }; + + expect(evaluateRule(rule, { department: 'eng-backend' })).toBe(true); + expect(evaluateRule(rule, { department: 'dev-frontend' })).toBe(true); + }); + + it('should return false when claim does not start with any prefix', () => { + const rule: OidcAuthorizationRule = { + claim: 'department', + operator: AuthorizationOperator.STARTS_WITH, + value: ['eng-'], + }; + + expect(evaluateRule(rule, { department: 'marketing-team' })).toBe(false); + expect(evaluateRule(rule, { department: 'backend-eng' })).toBe(false); + }); + }); + + describe('ENDS_WITH operator', () => { + it('should return true when claim ends with any suffix', () => { + const rule: OidcAuthorizationRule = { + claim: 'email', + operator: AuthorizationOperator.ENDS_WITH, + value: ['@gmail.com', '@company.com'], + }; + + expect(evaluateRule(rule, { email: 'user@gmail.com' })).toBe(true); + expect(evaluateRule(rule, { email: 'admin@company.com' })).toBe(true); + }); + + it('should return false when claim does not end with any suffix', () => { + const rule: OidcAuthorizationRule = { + claim: 'email', + operator: AuthorizationOperator.ENDS_WITH, + value: ['@company.com'], + }; + + expect(evaluateRule(rule, { email: 'user@other.org' })).toBe(false); + }); + + it('should handle domain validation correctly', () => { + const rule: OidcAuthorizationRule = { + claim: 'hd', + operator: AuthorizationOperator.ENDS_WITH, + value: ['.edu'], + }; + + expect(evaluateRule(rule, { hd: 'university.edu' })).toBe(true); + expect(evaluateRule(rule, { hd: 'college.edu' })).toBe(true); + expect(evaluateRule(rule, { hd: 'company.com' })).toBe(false); + }); + }); + + describe('Missing or undefined claims', () => { + it('should return false when claim is missing', () => { + const rule: OidcAuthorizationRule = { + claim: 'email', + operator: AuthorizationOperator.EQUALS, + value: ['user@example.com'], + }; + + expect(evaluateRule(rule, {})).toBe(false); + expect(evaluateRule(rule, { name: 'John' })).toBe(false); + }); + + it('should return false when claim is null', () => { + const rule: OidcAuthorizationRule = { + claim: 'email', + operator: AuthorizationOperator.EQUALS, + value: ['user@example.com'], + }; + + expect(evaluateRule(rule, { email: null })).toBe(false); + }); + + it('should return false when claim is undefined', () => { + const rule: OidcAuthorizationRule = { + claim: 'email', + operator: AuthorizationOperator.EQUALS, + value: ['user@example.com'], + }; + + expect(evaluateRule(rule, { email: undefined })).toBe(false); + }); + }); + + describe('Type coercion', () => { + it('should convert values to strings for comparison', () => { + const rule: OidcAuthorizationRule = { + claim: 'user_id', + operator: AuthorizationOperator.EQUALS, + value: ['12345'], + }; + + expect(evaluateRule(rule, { user_id: 12345 })).toBe(true); + expect(evaluateRule(rule, { user_id: '12345' })).toBe(true); + }); + + it('should handle boolean values', () => { + const rule: OidcAuthorizationRule = { + claim: 'is_admin', + operator: AuthorizationOperator.EQUALS, + value: ['true'], + }; + + expect(evaluateRule(rule, { is_admin: true })).toBe(true); + expect(evaluateRule(rule, { is_admin: 'true' })).toBe(true); + expect(evaluateRule(rule, { is_admin: false })).toBe(false); + }); + }); + + describe('Multiple rules evaluation', () => { + // Access the private method through any type casting for testing + const evaluateAuthorizationRules = ( + rules: OidcAuthorizationRule[], + claims: any, + mode: AuthorizationRuleMode = AuthorizationRuleMode.OR + ): boolean => { + return (service as any).evaluateAuthorizationRules(rules, claims, mode); + }; + + it('should require ANY rule to pass (OR logic)', () => { + const rules: OidcAuthorizationRule[] = [ + { + claim: 'email', + operator: AuthorizationOperator.ENDS_WITH, + value: ['@company.com'], + }, + { + claim: 'department', + operator: AuthorizationOperator.EQUALS, + value: ['engineering', 'development'], + }, + ]; + + // Both rules pass + expect( + evaluateAuthorizationRules(rules, { + email: 'user@company.com', + department: 'engineering', + }) + ).toBe(true); + + // Only email rule passes + expect( + evaluateAuthorizationRules(rules, { + email: 'user@company.com', + department: 'marketing', + }) + ).toBe(true); + + // Only department rule passes + expect( + evaluateAuthorizationRules(rules, { + email: 'user@other.com', + department: 'engineering', + }) + ).toBe(true); + + // Neither rule passes + expect( + evaluateAuthorizationRules(rules, { + email: 'user@other.com', + department: 'marketing', + }) + ).toBe(false); + }); + + it('should return false when no rules are defined', () => { + expect(evaluateAuthorizationRules([], { email: 'any@email.com' })).toBe(false); + }); + + describe('AND logic mode', () => { + it('should require ALL rules to pass when using AND mode', () => { + const rules: OidcAuthorizationRule[] = [ + { + claim: 'email', + operator: AuthorizationOperator.ENDS_WITH, + value: ['@company.com'], + }, + { + claim: 'department', + operator: AuthorizationOperator.EQUALS, + value: ['engineering'], + }, + ]; + + // Both rules pass - should return true + expect( + evaluateAuthorizationRules( + rules, + { + email: 'user@company.com', + department: 'engineering', + }, + AuthorizationRuleMode.AND + ) + ).toBe(true); + + // Only email rule passes - should return false + expect( + evaluateAuthorizationRules( + rules, + { + email: 'user@company.com', + department: 'marketing', + }, + AuthorizationRuleMode.AND + ) + ).toBe(false); + + // Only department rule passes - should return false + expect( + evaluateAuthorizationRules( + rules, + { + email: 'user@other.com', + department: 'engineering', + }, + AuthorizationRuleMode.AND + ) + ).toBe(false); + + // Neither rule passes - should return false + expect( + evaluateAuthorizationRules( + rules, + { + email: 'user@other.com', + department: 'marketing', + }, + AuthorizationRuleMode.AND + ) + ).toBe(false); + }); + + it('should handle complex AND conditions with multiple operators', () => { + const rules: OidcAuthorizationRule[] = [ + { + claim: 'email', + operator: AuthorizationOperator.ENDS_WITH, + value: ['@company.com'], + }, + { + claim: 'name', + operator: AuthorizationOperator.STARTS_WITH, + value: ['John', 'Jane'], + }, + { + claim: 'role', + operator: AuthorizationOperator.CONTAINS, + value: ['admin', 'manager'], + }, + ]; + + // All rules pass + expect( + evaluateAuthorizationRules( + rules, + { + email: 'john.doe@company.com', + name: 'John Doe', + role: 'senior-admin', + }, + AuthorizationRuleMode.AND + ) + ).toBe(true); + + // Missing one condition (role doesn't contain admin/manager) + expect( + evaluateAuthorizationRules( + rules, + { + email: 'john.doe@company.com', + name: 'John Doe', + role: 'developer', + }, + AuthorizationRuleMode.AND + ) + ).toBe(false); + }); + + it('should return true with single rule in AND mode', () => { + const rules: OidcAuthorizationRule[] = [ + { + claim: 'email', + operator: AuthorizationOperator.EQUALS, + value: ['admin@company.com'], + }, + ]; + + expect( + evaluateAuthorizationRules( + rules, + { email: 'admin@company.com' }, + AuthorizationRuleMode.AND + ) + ).toBe(true); + + expect( + evaluateAuthorizationRules( + rules, + { email: 'user@company.com' }, + AuthorizationRuleMode.AND + ) + ).toBe(false); + }); + }); + + describe('OR logic mode (default)', () => { + it('should handle complex OR conditions with multiple operators', () => { + const rules: OidcAuthorizationRule[] = [ + { + claim: 'email', + operator: AuthorizationOperator.EQUALS, + value: ['admin@company.com', 'superuser@company.com'], + }, + { + claim: 'groups', + operator: AuthorizationOperator.CONTAINS, + value: ['administrators', 'power-users'], + }, + { + claim: 'department', + operator: AuthorizationOperator.STARTS_WITH, + value: ['IT-', 'SEC-'], + }, + ]; + + // Multiple rules pass + expect( + evaluateAuthorizationRules( + rules, + { + email: 'admin@company.com', + groups: 'administrators', + department: 'IT-Support', + }, + AuthorizationRuleMode.OR + ) + ).toBe(true); + + // Only one rule passes (department) + expect( + evaluateAuthorizationRules( + rules, + { + email: 'user@company.com', + groups: 'users', + department: 'SEC-Operations', + }, + AuthorizationRuleMode.OR + ) + ).toBe(true); + + // No rules pass + expect( + evaluateAuthorizationRules( + rules, + { + email: 'user@company.com', + groups: 'users', + department: 'HR-Management', + }, + AuthorizationRuleMode.OR + ) + ).toBe(false); + }); + + it('should use OR mode as default when mode is not specified', () => { + const rules: OidcAuthorizationRule[] = [ + { + claim: 'email', + operator: AuthorizationOperator.ENDS_WITH, + value: ['@company.com'], + }, + { + claim: 'special_user', + operator: AuthorizationOperator.EQUALS, + value: ['true'], + }, + ]; + + // Test without explicit mode (should default to OR) + expect( + evaluateAuthorizationRules(rules, { + email: 'user@other.com', + special_user: 'true', + }) + ).toBe(true); + }); + }); + + describe('Edge cases and boundary conditions', () => { + it('should handle empty rules array correctly', () => { + expect( + evaluateAuthorizationRules( + [], + { email: 'any@email.com' }, + AuthorizationRuleMode.AND + ) + ).toBe(false); + expect( + evaluateAuthorizationRules( + [], + { email: 'any@email.com' }, + AuthorizationRuleMode.OR + ) + ).toBe(false); + }); + + it('should handle rules with empty value arrays', () => { + const rules: OidcAuthorizationRule[] = [ + { + claim: 'email', + operator: AuthorizationOperator.EQUALS, + value: [], + }, + ]; + + expect( + evaluateAuthorizationRules( + rules, + { email: 'user@company.com' }, + AuthorizationRuleMode.OR + ) + ).toBe(false); + + expect( + evaluateAuthorizationRules( + rules, + { email: 'user@company.com' }, + AuthorizationRuleMode.AND + ) + ).toBe(false); + }); + + it('should handle missing claims in AND mode', () => { + const rules: OidcAuthorizationRule[] = [ + { + claim: 'email', + operator: AuthorizationOperator.ENDS_WITH, + value: ['@company.com'], + }, + { + claim: 'department', + operator: AuthorizationOperator.EQUALS, + value: ['engineering'], + }, + ]; + + // Missing department claim + expect( + evaluateAuthorizationRules( + rules, + { email: 'user@company.com' }, + AuthorizationRuleMode.AND + ) + ).toBe(false); + }); + + it('should handle missing claims in OR mode', () => { + const rules: OidcAuthorizationRule[] = [ + { + claim: 'email', + operator: AuthorizationOperator.ENDS_WITH, + value: ['@company.com'], + }, + { + claim: 'department', + operator: AuthorizationOperator.EQUALS, + value: ['engineering'], + }, + ]; + + // Missing department claim but email passes + expect( + evaluateAuthorizationRules( + rules, + { email: 'user@company.com' }, + AuthorizationRuleMode.OR + ) + ).toBe(true); + + // Missing both claims + expect( + evaluateAuthorizationRules(rules, { other: 'value' }, AuthorizationRuleMode.OR) + ).toBe(false); + }); + + it('should handle all claims missing in both modes', () => { + const rules: OidcAuthorizationRule[] = [ + { + claim: 'email', + operator: AuthorizationOperator.EQUALS, + value: ['admin@company.com'], + }, + { + claim: 'role', + operator: AuthorizationOperator.EQUALS, + value: ['admin'], + }, + ]; + + const claimsWithoutRequired = { name: 'John', sub: '12345' }; + + expect( + evaluateAuthorizationRules( + rules, + claimsWithoutRequired, + AuthorizationRuleMode.AND + ) + ).toBe(false); + + expect( + evaluateAuthorizationRules( + rules, + claimsWithoutRequired, + AuthorizationRuleMode.OR + ) + ).toBe(false); + }); + }); + + describe('Real-world authorization scenarios', () => { + it('should handle Google Workspace domain + specific users (OR)', () => { + const rules: OidcAuthorizationRule[] = [ + { + claim: 'hd', + operator: AuthorizationOperator.EQUALS, + value: ['company.com'], + }, + { + claim: 'email', + operator: AuthorizationOperator.EQUALS, + value: ['contractor1@gmail.com', 'contractor2@outlook.com'], + }, + ]; + + // Company domain user + expect( + evaluateAuthorizationRules( + rules, + { hd: 'company.com', email: 'employee@company.com' }, + AuthorizationRuleMode.OR + ) + ).toBe(true); + + // Allowed contractor + expect( + evaluateAuthorizationRules( + rules, + { email: 'contractor1@gmail.com' }, + AuthorizationRuleMode.OR + ) + ).toBe(true); + + // Unauthorized user + expect( + evaluateAuthorizationRules( + rules, + { email: 'random@gmail.com' }, + AuthorizationRuleMode.OR + ) + ).toBe(false); + }); + + it('should handle department + role requirements (AND)', () => { + const rules: OidcAuthorizationRule[] = [ + { + claim: 'department', + operator: AuthorizationOperator.EQUALS, + value: ['engineering', 'devops'], + }, + { + claim: 'role', + operator: AuthorizationOperator.CONTAINS, + value: ['senior', 'lead', 'manager'], + }, + ]; + + // Senior engineer - both conditions met + expect( + evaluateAuthorizationRules( + rules, + { department: 'engineering', role: 'senior-engineer' }, + AuthorizationRuleMode.AND + ) + ).toBe(true); + + // Junior engineer - department ok, but not senior + expect( + evaluateAuthorizationRules( + rules, + { department: 'engineering', role: 'junior-engineer' }, + AuthorizationRuleMode.AND + ) + ).toBe(false); + + // Senior in wrong department + expect( + evaluateAuthorizationRules( + rules, + { department: 'marketing', role: 'senior-marketer' }, + AuthorizationRuleMode.AND + ) + ).toBe(false); + }); + + it('should handle email domain + group membership (AND)', () => { + const rules: OidcAuthorizationRule[] = [ + { + claim: 'email', + operator: AuthorizationOperator.ENDS_WITH, + value: ['@trusted.com', '@partner.com'], + }, + { + claim: 'groups', + operator: AuthorizationOperator.CONTAINS, + value: ['vpn-access'], + }, + ]; + + // Trusted domain with VPN access + expect( + evaluateAuthorizationRules( + rules, + { email: 'user@trusted.com', groups: 'vpn-access,developers' }, + AuthorizationRuleMode.AND + ) + ).toBe(true); + + // Trusted domain without VPN access + expect( + evaluateAuthorizationRules( + rules, + { email: 'user@trusted.com', groups: 'developers' }, + AuthorizationRuleMode.AND + ) + ).toBe(false); + }); + }); + }); + + describe('Real-world scenarios', () => { + it('should authorize Google Workspace users by domain', () => { + const rule: OidcAuthorizationRule = { + claim: 'hd', + operator: AuthorizationOperator.EQUALS, + value: ['company.com'], + }; + + expect(evaluateRule(rule, { hd: 'company.com', email: 'user@company.com' })).toBe(true); + expect(evaluateRule(rule, { hd: 'other.com', email: 'user@other.com' })).toBe(false); + }); + + it('should authorize users by email domain pattern', () => { + const rule: OidcAuthorizationRule = { + claim: 'email', + operator: AuthorizationOperator.ENDS_WITH, + value: ['@company.com', '@subsidiary.company.com'], + }; + + expect(evaluateRule(rule, { email: 'john@company.com' })).toBe(true); + expect(evaluateRule(rule, { email: 'jane@subsidiary.company.com' })).toBe(true); + expect(evaluateRule(rule, { email: 'external@gmail.com' })).toBe(false); + }); + + it('should authorize specific users by subject ID', () => { + const rule: OidcAuthorizationRule = { + claim: 'sub', + operator: AuthorizationOperator.EQUALS, + value: ['user123', 'user456', 'user789'], + }; + + expect(evaluateRule(rule, { sub: 'user456' })).toBe(true); + expect(evaluateRule(rule, { sub: 'user999' })).toBe(false); + }); + + it('should authorize users in specific groups', () => { + const rule: OidcAuthorizationRule = { + claim: 'groups', + operator: AuthorizationOperator.CONTAINS, + value: ['admin', 'developer'], + }; + + // When groups is a string containing the role + expect(evaluateRule(rule, { groups: 'user,admin,viewer' })).toBe(true); + expect(evaluateRule(rule, { groups: 'developer,tester' })).toBe(true); + expect(evaluateRule(rule, { groups: 'user,viewer' })).toBe(false); + }); + }); + }); + + describe('checkAuthorization', () => { + // Access the private method through any type casting for testing + const checkAuthorization = async (provider: OidcProvider, claims: any): Promise => { + return (service as any).checkAuthorization(provider, claims); + }; + + it('should throw error when no authorization rules are configured', async () => { + const provider: OidcProvider = { + id: 'test', + name: 'Test Provider', + clientId: 'test-client', + issuer: 'https://test.com', + scopes: ['openid'], + authorizationRules: [], + } as OidcProvider; + + await expect(checkAuthorization(provider, { sub: 'user123' })).rejects.toThrow(); + }); + + it('should throw error when authorization rules do not match', async () => { + const provider: OidcProvider = { + id: 'test', + name: 'Test Provider', + clientId: 'test-client', + issuer: 'https://test.com', + scopes: ['openid'], + authorizationRules: [ + { + claim: 'email', + operator: AuthorizationOperator.ENDS_WITH, + value: ['@company.com'], + }, + ], + } as OidcProvider; + + await expect(checkAuthorization(provider, { email: 'user@other.com' })).rejects.toThrow( + new UnauthorizedException( + 'Access denied: Your account does not meet the authorization requirements for Test Provider.' + ) + ); + }); + + it('should not throw when authorization rules match', async () => { + const provider: OidcProvider = { + id: 'test', + name: 'Test Provider', + clientId: 'test-client', + issuer: 'https://test.com', + scopes: ['openid'], + authorizationRules: [ + { + claim: 'email', + operator: AuthorizationOperator.ENDS_WITH, + value: ['@company.com'], + }, + ], + } as OidcProvider; + + await expect( + checkAuthorization(provider, { email: 'user@company.com' }) + ).resolves.toBeUndefined(); + }); + + it('should authorize when ANY rule matches (OR logic)', async () => { + const provider: OidcProvider = { + id: 'test', + name: 'Test Provider', + clientId: 'test-client', + issuer: 'https://test.com', + scopes: ['openid'], + authorizationRules: [ + { + claim: 'email', + operator: AuthorizationOperator.ENDS_WITH, + value: ['@company.com'], + }, + { + claim: 'email', + operator: AuthorizationOperator.ENDS_WITH, + value: ['@partner.com'], + }, + { + claim: 'sub', + operator: AuthorizationOperator.EQUALS, + value: ['specific-user-id'], + }, + ], + } as OidcProvider; + + // Should pass with @partner.com email (second rule) + await expect( + checkAuthorization(provider, { email: 'user@partner.com', sub: 'other-id' }) + ).resolves.toBeUndefined(); + + // Should pass with specific sub (third rule) + await expect( + checkAuthorization(provider, { email: 'user@external.com', sub: 'specific-user-id' }) + ).resolves.toBeUndefined(); + + // Should fail when no rules match + await expect( + checkAuthorization(provider, { email: 'user@external.com', sub: 'other-id' }) + ).rejects.toThrow(); + }); + + it('should authorize using AND mode when all rules match', async () => { + const provider: OidcProvider = { + id: 'test', + name: 'Test Provider', + clientId: 'test-client', + issuer: 'https://test.com', + scopes: ['openid'], + authorizationRuleMode: AuthorizationRuleMode.AND, + authorizationRules: [ + { + claim: 'email', + operator: AuthorizationOperator.ENDS_WITH, + value: ['@company.com'], + }, + { + claim: 'department', + operator: AuthorizationOperator.EQUALS, + value: ['engineering'], + }, + ], + } as OidcProvider; + + // Should pass when both rules match + await expect( + checkAuthorization(provider, { + email: 'user@company.com', + department: 'engineering', + }) + ).resolves.toBeUndefined(); + }); + + it('should reject using AND mode when only some rules match', async () => { + const provider: OidcProvider = { + id: 'test', + name: 'Test Provider', + clientId: 'test-client', + issuer: 'https://test.com', + scopes: ['openid'], + authorizationRuleMode: AuthorizationRuleMode.AND, + authorizationRules: [ + { + claim: 'email', + operator: AuthorizationOperator.ENDS_WITH, + value: ['@company.com'], + }, + { + claim: 'department', + operator: AuthorizationOperator.EQUALS, + value: ['engineering'], + }, + ], + } as OidcProvider; + + // Should fail when only first rule matches + await expect( + checkAuthorization(provider, { + email: 'user@company.com', + department: 'marketing', + }) + ).rejects.toThrow(); + + // Should fail when only second rule matches + await expect( + checkAuthorization(provider, { + email: 'user@external.com', + department: 'engineering', + }) + ).rejects.toThrow(); + }); + + it('should default to OR mode when authorizationRuleMode is not specified', async () => { + const provider: OidcProvider = { + id: 'test', + name: 'Test Provider', + clientId: 'test-client', + issuer: 'https://test.com', + scopes: ['openid'], + // authorizationRuleMode not specified, should default to OR + authorizationRules: [ + { + claim: 'email', + operator: AuthorizationOperator.ENDS_WITH, + value: ['@company.com'], + }, + { + claim: 'department', + operator: AuthorizationOperator.EQUALS, + value: ['engineering'], + }, + ], + } as OidcProvider; + + // Should pass when only first rule matches (OR mode default) + await expect( + checkAuthorization(provider, { + email: 'user@company.com', + department: 'marketing', + }) + ).resolves.toBeUndefined(); + }); + + it('should work with OR mode explicitly set', async () => { + const provider: OidcProvider = { + id: 'test', + name: 'Test Provider', + clientId: 'test-client', + issuer: 'https://test.com', + scopes: ['openid'], + authorizationRuleMode: AuthorizationRuleMode.OR, + authorizationRules: [ + { + claim: 'email', + operator: AuthorizationOperator.ENDS_WITH, + value: ['@company.com'], + }, + { + claim: 'department', + operator: AuthorizationOperator.EQUALS, + value: ['engineering'], + }, + ], + } as OidcProvider; + + // Should pass when only first rule matches + await expect( + checkAuthorization(provider, { + email: 'user@company.com', + department: 'marketing', + }) + ).resolves.toBeUndefined(); + + // Should pass when only second rule matches + await expect( + checkAuthorization(provider, { + email: 'user@external.com', + department: 'engineering', + }) + ).resolves.toBeUndefined(); + }); + }); + + describe('Manual Configuration (No Discovery)', () => { + it('should create manual configuration when discovery fails but manual endpoints are provided', async () => { + const provider: OidcProvider = { + id: 'manual-provider', + name: 'Manual Provider', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + issuer: 'https://manual.example.com', + authorizationEndpoint: 'https://manual.example.com/auth', + tokenEndpoint: 'https://manual.example.com/token', + jwksUri: 'https://manual.example.com/jwks', + scopes: ['openid', 'profile'], + authorizationRules: [], + }; + + oidcConfig.getProvider.mockResolvedValue(provider); + + // Mock discovery to fail + validationService.performDiscovery = vi + .fn() + .mockRejectedValue(new Error('Discovery failed')); + + // Access the private method + const getOrCreateConfig = async (provider: OidcProvider) => { + return (service as any).getOrCreateConfig(provider); + }; + + const config = await getOrCreateConfig(provider); + + // Verify the configuration was created with the correct endpoints + expect(config).toBeDefined(); + expect(config.serverMetadata().authorization_endpoint).toBe( + 'https://manual.example.com/auth' + ); + expect(config.serverMetadata().token_endpoint).toBe('https://manual.example.com/token'); + expect(config.serverMetadata().jwks_uri).toBe('https://manual.example.com/jwks'); + expect(config.serverMetadata().issuer).toBe('https://manual.example.com'); + }); + + it('should create manual configuration with fallback issuer when not provided', async () => { + const provider: OidcProvider = { + id: 'manual-provider-no-issuer', + name: 'Manual Provider No Issuer', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + issuer: '', // Empty issuer should skip discovery and use manual endpoints + authorizationEndpoint: 'https://manual.example.com/auth', + tokenEndpoint: 'https://manual.example.com/token', + scopes: ['openid', 'profile'], + authorizationRules: [], + }; + + oidcConfig.getProvider.mockResolvedValue(provider); + + // No need to mock discovery since it won't be called with empty issuer + + // Access the private method + const getOrCreateConfig = async (provider: OidcProvider) => { + return (service as any).getOrCreateConfig(provider); + }; + + const config = await getOrCreateConfig(provider); + + // Verify the configuration was created with fallback issuer + expect(config).toBeDefined(); + expect(config.serverMetadata().issuer).toBe('manual-manual-provider-no-issuer'); + expect(config.serverMetadata().authorization_endpoint).toBe( + 'https://manual.example.com/auth' + ); + expect(config.serverMetadata().token_endpoint).toBe('https://manual.example.com/token'); + }); + + it('should handle manual configuration with client secret properly', async () => { + const provider: OidcProvider = { + id: 'manual-with-secret', + name: 'Manual With Secret', + clientId: 'test-client-id', + clientSecret: 'secret-123', + issuer: 'https://manual.example.com', + authorizationEndpoint: 'https://manual.example.com/auth', + tokenEndpoint: 'https://manual.example.com/token', + scopes: ['openid', 'profile'], + authorizationRules: [], + }; + + oidcConfig.getProvider.mockResolvedValue(provider); + + // Mock discovery to fail + validationService.performDiscovery = vi + .fn() + .mockRejectedValue(new Error('Discovery failed')); + + // Access the private method + const getOrCreateConfig = async (provider: OidcProvider) => { + return (service as any).getOrCreateConfig(provider); + }; + + const config = await getOrCreateConfig(provider); + + // Verify configuration was created successfully + expect(config).toBeDefined(); + expect(config.clientMetadata().client_secret).toBe('secret-123'); + }); + + it('should handle manual configuration without client secret (public client)', async () => { + const provider: OidcProvider = { + id: 'manual-public-client', + name: 'Manual Public Client', + clientId: 'public-client-id', + // No client secret + issuer: 'https://manual.example.com', + authorizationEndpoint: 'https://manual.example.com/auth', + tokenEndpoint: 'https://manual.example.com/token', + scopes: ['openid', 'profile'], + authorizationRules: [], + }; + + oidcConfig.getProvider.mockResolvedValue(provider); + + // Mock discovery to fail + validationService.performDiscovery = vi + .fn() + .mockRejectedValue(new Error('Discovery failed')); + + // Access the private method + const getOrCreateConfig = async (provider: OidcProvider) => { + return (service as any).getOrCreateConfig(provider); + }; + + const config = await getOrCreateConfig(provider); + + // Verify configuration was created successfully for public client + expect(config).toBeDefined(); + expect(config.clientMetadata().client_secret).toBeUndefined(); + }); + + it('should throw error when discovery fails and no manual endpoints provided', async () => { + const provider: OidcProvider = { + id: 'no-manual-endpoints', + name: 'No Manual Endpoints', + clientId: 'test-client-id', + issuer: 'https://broken.example.com', + // Missing authorizationEndpoint and tokenEndpoint + scopes: ['openid', 'profile'], + authorizationRules: [], + }; + + oidcConfig.getProvider.mockResolvedValue(provider); + + // Mock discovery to fail + validationService.performDiscovery = vi + .fn() + .mockRejectedValue(new Error('Discovery failed')); + + // Access the private method + const getOrCreateConfig = async (provider: OidcProvider) => { + return (service as any).getOrCreateConfig(provider); + }; + + await expect(getOrCreateConfig(provider)).rejects.toThrow(UnauthorizedException); + }); + + it('should throw error when only authorization endpoint is provided', async () => { + const provider: OidcProvider = { + id: 'partial-manual-endpoints', + name: 'Partial Manual Endpoints', + clientId: 'test-client-id', + issuer: 'https://broken.example.com', + authorizationEndpoint: 'https://manual.example.com/auth', + // Missing tokenEndpoint + scopes: ['openid', 'profile'], + authorizationRules: [], + }; + + oidcConfig.getProvider.mockResolvedValue(provider); + + // Mock discovery to fail + validationService.performDiscovery = vi + .fn() + .mockRejectedValue(new Error('Discovery failed')); + + // Access the private method + const getOrCreateConfig = async (provider: OidcProvider) => { + return (service as any).getOrCreateConfig(provider); + }; + + await expect(getOrCreateConfig(provider)).rejects.toThrow(UnauthorizedException); + }); + + it('should cache manual configuration properly', async () => { + const provider: OidcProvider = { + id: 'cache-test', + name: 'Cache Test', + clientId: 'test-client-id', + clientSecret: 'test-secret', + issuer: 'https://manual.example.com', + authorizationEndpoint: 'https://manual.example.com/auth', + tokenEndpoint: 'https://manual.example.com/token', + scopes: ['openid', 'profile'], + authorizationRules: [], + }; + + oidcConfig.getProvider.mockResolvedValue(provider); + + // Mock discovery to fail + validationService.performDiscovery = vi + .fn() + .mockRejectedValue(new Error('Discovery failed')); + + // Access the private method + const getOrCreateConfig = async (provider: OidcProvider) => { + return (service as any).getOrCreateConfig(provider); + }; + + // First call should create configuration + const config1 = await getOrCreateConfig(provider); + + // Second call should return cached configuration + const config2 = await getOrCreateConfig(provider); + + expect(config1).toBe(config2); // Should be the exact same instance + expect(validationService.performDiscovery).toHaveBeenCalledTimes(1); // Only called once due to caching + }); + + it('should handle HTTP endpoints with allowInsecureRequests', async () => { + const provider: OidcProvider = { + id: 'http-endpoints', + name: 'HTTP Endpoints', + clientId: 'test-client-id', + clientSecret: 'test-secret', + issuer: 'http://manual.example.com', // HTTP instead of HTTPS + authorizationEndpoint: 'http://manual.example.com/auth', + tokenEndpoint: 'http://manual.example.com/token', + scopes: ['openid', 'profile'], + authorizationRules: [], + }; + + oidcConfig.getProvider.mockResolvedValue(provider); + + // Mock discovery to fail + validationService.performDiscovery = vi + .fn() + .mockRejectedValue(new Error('Discovery failed')); + + // Access the private method + const getOrCreateConfig = async (provider: OidcProvider) => { + return (service as any).getOrCreateConfig(provider); + }; + + const config = await getOrCreateConfig(provider); + + // Verify configuration was created successfully even with HTTP + expect(config).toBeDefined(); + expect(config.serverMetadata().token_endpoint).toBe('http://manual.example.com/token'); + expect(config.serverMetadata().authorization_endpoint).toBe( + 'http://manual.example.com/auth' + ); + }); + }); + + describe('getAuthorizationUrl', () => { + it('should generate authorization URL with custom authorization endpoint', async () => { + const provider: OidcProvider = { + id: 'test-provider', + name: 'Test Provider', + clientId: 'test-client-id', + issuer: 'https://example.com', + authorizationEndpoint: 'https://custom.example.com/auth', + scopes: ['openid', 'profile'], + authorizationRules: [], + }; + + oidcConfig.getProvider.mockResolvedValue(provider); + + const authUrl = await service.getAuthorizationUrl( + 'test-provider', + 'test-state', + 'localhost:3001' + ); + + expect(authUrl).toContain('https://custom.example.com/auth'); + expect(authUrl).toContain('client_id=test-client-id'); + expect(authUrl).toContain('response_type=code'); + expect(authUrl).toContain('scope=openid+profile'); + // State should start with provider ID followed by secure state token + expect(authUrl).toMatch(/state=test-provider%3A[a-f0-9]+\.[0-9]+\.[a-f0-9]+/); + expect(authUrl).toContain('redirect_uri='); + }); + + it('should encode provider ID in state parameter', async () => { + const provider: OidcProvider = { + id: 'encode-test-provider', + name: 'Encode Test Provider', + clientId: 'test-client-id', + issuer: 'https://example.com', + authorizationEndpoint: 'https://example.com/auth', + scopes: ['openid', 'email'], + authorizationRules: [], + }; + + oidcConfig.getProvider.mockResolvedValue(provider); + + const authUrl = await service.getAuthorizationUrl('encode-test-provider', 'original-state'); + + // Verify that the state parameter includes provider ID at the start + expect(authUrl).toMatch(/state=encode-test-provider%3A[a-f0-9]+\.[0-9]+\.[a-f0-9]+/); + }); + + it('should throw error when provider not found', async () => { + oidcConfig.getProvider.mockResolvedValue(null); + + await expect( + service.getAuthorizationUrl('nonexistent-provider', 'test-state') + ).rejects.toThrow('Provider nonexistent-provider not found'); + }); + + it('should handle custom scopes properly', async () => { + const provider: OidcProvider = { + id: 'custom-scopes-provider', + name: 'Custom Scopes Provider', + clientId: 'test-client-id', + issuer: 'https://example.com', + authorizationEndpoint: 'https://example.com/auth', + scopes: ['openid', 'profile', 'groups', 'custom:scope'], + authorizationRules: [], + }; + + oidcConfig.getProvider.mockResolvedValue(provider); + + const authUrl = await service.getAuthorizationUrl('custom-scopes-provider', 'test-state'); + + expect(authUrl).toContain('scope=openid+profile+groups+custom%3Ascope'); + }); + }); + + describe('handleCallback', () => { + it('should throw error when provider not found in callback', async () => { + oidcConfig.getProvider.mockResolvedValue(null); + + await expect( + service.handleCallback('nonexistent-provider', 'code', 'redirect-uri') + ).rejects.toThrow('Provider nonexistent-provider not found'); + }); + + it('should handle malformed state parameter', async () => { + await expect( + service.handleCallback('invalid-state', 'code', 'redirect-uri') + ).rejects.toThrow(UnauthorizedException); + }); + + it('should call getProvider with the provided provider ID', async () => { + const provider: OidcProvider = { + id: 'test-provider', + name: 'Test Provider', + clientId: 'test-client-id', + issuer: 'https://example.com', + scopes: ['openid'], + authorizationRules: [], + }; + + oidcConfig.getProvider.mockResolvedValue(provider); + + // This will fail during token exchange, but we're testing the provider lookup logic + await expect( + service.handleCallback('test-provider', 'code', 'redirect-uri') + ).rejects.toThrow(UnauthorizedException); + + // Verify the provider was looked up with the correct ID + expect(oidcConfig.getProvider).toHaveBeenCalledWith('test-provider'); + }); + }); + + describe('validateProvider', () => { + it('should delegate to validation service and return result', async () => { + const provider: OidcProvider = { + id: 'validate-provider', + name: 'Validate Provider', + clientId: 'test-client-id', + issuer: 'https://example.com', + scopes: ['openid'], + authorizationRules: [], + }; + + const expectedResult = { + isValid: true, + authorizationEndpoint: 'https://example.com/auth', + tokenEndpoint: 'https://example.com/token', + }; + + validationService.validateProvider.mockResolvedValue(expectedResult); + + const result = await service.validateProvider(provider); + + expect(result).toEqual(expectedResult); + expect(validationService.validateProvider).toHaveBeenCalledWith(provider); + }); + + it('should clear config cache before validation', async () => { + const provider: OidcProvider = { + id: 'cache-clear-provider', + name: 'Cache Clear Provider', + clientId: 'test-client-id', + issuer: 'https://example.com', + scopes: ['openid'], + authorizationRules: [], + }; + + const expectedResult = { + isValid: false, + error: 'Validation failed', + }; + + validationService.validateProvider.mockResolvedValue(expectedResult); + + const result = await service.validateProvider(provider); + + expect(result).toEqual(expectedResult); + // Verify the cache was cleared by checking the method was called + expect(validationService.validateProvider).toHaveBeenCalledWith(provider); + }); + }); + + describe('getRedirectUri (private method)', () => { + it('should generate correct redirect URI with localhost (development)', () => { + const getRedirectUri = (service as any).getRedirectUri.bind(service); + const redirectUri = getRedirectUri('localhost:3001'); + + expect(redirectUri).toBe('http://localhost:3000/graphql/api/auth/oidc/callback'); + }); + + it('should generate correct redirect URI with non-localhost host', () => { + const getRedirectUri = (service as any).getRedirectUri.bind(service); + + // Mock the ConfigService to return a production base URL + configService.get.mockReturnValue('https://example.com'); + + const redirectUri = getRedirectUri('example.com:443'); + + expect(redirectUri).toBe('https://example.com/graphql/api/auth/oidc/callback'); + }); + + it('should use default redirect URI when no request host provided', () => { + const getRedirectUri = (service as any).getRedirectUri.bind(service); + + // Mock the ConfigService to return a default value + configService.get.mockReturnValue('http://tower.local'); + + const redirectUri = getRedirectUri(); + + expect(redirectUri).toBe('http://tower.local/graphql/api/auth/oidc/callback'); + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/sso/oidc-auth.service.ts b/api/src/unraid-api/graph/resolvers/sso/oidc-auth.service.ts new file mode 100644 index 000000000..f0424c2b2 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/sso/oidc-auth.service.ts @@ -0,0 +1,667 @@ +import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { decodeJwt } from 'jose'; +import * as client from 'openid-client'; + +import { OidcConfigPersistence } from '@app/unraid-api/graph/resolvers/sso/oidc-config.service.js'; +import { + AuthorizationOperator, + AuthorizationRuleMode, + OidcAuthorizationRule, + OidcProvider, +} from '@app/unraid-api/graph/resolvers/sso/oidc-provider.model.js'; +import { OidcSessionService } from '@app/unraid-api/graph/resolvers/sso/oidc-session.service.js'; +import { OidcStateService } from '@app/unraid-api/graph/resolvers/sso/oidc-state.service.js'; +import { OidcValidationService } from '@app/unraid-api/graph/resolvers/sso/oidc-validation.service.js'; + +interface JwtClaims { + sub?: string; + email?: string; + name?: string; + hd?: string; // Google hosted domain + [claim: string]: unknown; +} + +@Injectable() +export class OidcAuthService { + private readonly logger = new Logger(OidcAuthService.name); + private readonly configCache = new Map(); + + constructor( + private readonly configService: ConfigService, + private readonly oidcConfig: OidcConfigPersistence, + private readonly sessionService: OidcSessionService, + private readonly stateService: OidcStateService, + private readonly validationService: OidcValidationService + ) {} + + async getAuthorizationUrl(providerId: string, state: string, requestHost?: string): Promise { + const provider = await this.oidcConfig.getProvider(providerId); + if (!provider) { + throw new UnauthorizedException(`Provider ${providerId} not found`); + } + + const redirectUri = this.getRedirectUri(requestHost); + + // Generate secure state with cryptographic signature + const secureState = this.stateService.generateSecureState(providerId, state); + + // Build authorization URL + if (provider.authorizationEndpoint) { + // Use custom authorization endpoint + const authUrl = new URL(provider.authorizationEndpoint); + + // Standard OAuth2 parameters + authUrl.searchParams.set('client_id', provider.clientId); + authUrl.searchParams.set('redirect_uri', redirectUri); + authUrl.searchParams.set('scope', provider.scopes.join(' ')); + authUrl.searchParams.set('state', secureState); + authUrl.searchParams.set('response_type', 'code'); + + return authUrl.href; + } + + // Use OIDC discovery for providers without custom endpoints + const config = await this.getOrCreateConfig(provider); + const parameters: Record = { + redirect_uri: redirectUri, + scope: provider.scopes.join(' '), + state: secureState, + response_type: 'code', + }; + + // For HTTP endpoints, we need to pass the allowInsecureRequests option + const serverUrl = new URL(provider.issuer || ''); + let clientOptions: any = undefined; + if (serverUrl.protocol === 'http:') { + this.logger.debug( + `Building authorization URL with allowInsecureRequests for ${provider.id}` + ); + clientOptions = { + execute: [client.allowInsecureRequests], + }; + } + + const authUrl = client.buildAuthorizationUrl(config, parameters); + + return authUrl.href; + } + + extractProviderFromState(state: string): { providerId: string; originalState: string } { + // Extract provider from state prefix (no decryption needed) + const providerId = this.stateService.extractProviderFromState(state); + + if (providerId) { + return { + providerId, + originalState: state, + }; + } + + // Fallback for unknown formats + return { + providerId: '', + originalState: state, + }; + } + + async handleCallback( + providerId: string, + code: string, + state: string, + requestHost?: string, + fullCallbackUrl?: string + ): Promise { + const provider = await this.oidcConfig.getProvider(providerId); + if (!provider) { + throw new UnauthorizedException(`Provider ${providerId} not found`); + } + + try { + const redirectUri = this.getRedirectUri(requestHost); + + // Always use openid-client for consistency + const config = await this.getOrCreateConfig(provider); + + // Log configuration details + this.logger.debug(`Provider ${providerId} config loaded`); + this.logger.debug(`Redirect URI: ${redirectUri}`); + + // Build current URL for token exchange + // CRITICAL: The URL used here MUST match the redirect_uri that was sent to the authorization endpoint + // Google expects the exact same redirect_uri during token exchange + const currentUrl = new URL(redirectUri); + currentUrl.searchParams.set('code', code); + currentUrl.searchParams.set('state', state); + + // Copy additional parameters from the actual callback if provided + if (fullCallbackUrl) { + const actualUrl = new URL(fullCallbackUrl); + // Copy over additional params that Google might have added (scope, authuser, prompt, etc) + // but DO NOT change the base URL or path + ['scope', 'authuser', 'prompt', 'hd', 'session_state', 'iss'].forEach((param) => { + const value = actualUrl.searchParams.get(param); + if (value && !currentUrl.searchParams.has(param)) { + currentUrl.searchParams.set(param, value); + } + }); + } + + // Google returns iss in the response, openid-client v6 expects it + // If not present, add it based on the provider's issuer + if (!currentUrl.searchParams.has('iss') && provider.issuer) { + currentUrl.searchParams.set('iss', provider.issuer); + } + + this.logger.debug(`Token exchange URL (matches redirect_uri): ${currentUrl.href}`); + + // Validate secure state + const stateValidation = this.stateService.validateSecureState(state, providerId); + if (!stateValidation.isValid) { + this.logger.error(`State validation failed: ${stateValidation.error}`); + throw new UnauthorizedException(stateValidation.error || 'Invalid state parameter'); + } + + const originalState = stateValidation.clientState!; + this.logger.debug(`Exchanging code for tokens with provider ${providerId}`); + this.logger.debug(`Client state extracted: ${originalState}`); + + // For openid-client v6, we need to prepare the authorization response + const authorizationResponse = new URLSearchParams(currentUrl.search); + + // Set the original client state for openid-client + authorizationResponse.set('state', originalState); + + // Create a new URL with the cleaned parameters + const cleanUrl = new URL(redirectUri); + cleanUrl.search = authorizationResponse.toString(); + + this.logger.debug(`Clean URL for token exchange: ${cleanUrl.href}`); + + let tokens; + try { + this.logger.debug(`Starting token exchange with openid-client`); + this.logger.debug(`Config issuer: ${config.serverMetadata().issuer}`); + this.logger.debug(`Config token endpoint: ${config.serverMetadata().token_endpoint}`); + + // For HTTP endpoints, we need to pass the allowInsecureRequests option + const serverUrl = new URL(provider.issuer || ''); + let clientOptions: any = undefined; + if (serverUrl.protocol === 'http:') { + this.logger.debug(`Token exchange with allowInsecureRequests for ${provider.id}`); + clientOptions = { + execute: [client.allowInsecureRequests], + }; + } + + tokens = await client.authorizationCodeGrant( + config, + cleanUrl, + { + expectedState: originalState, + }, + clientOptions + ); + this.logger.debug( + `Token exchange successful, received tokens: ${Object.keys(tokens).join(', ')}` + ); + } catch (tokenError) { + const errorMessage = + tokenError instanceof Error ? tokenError.message : String(tokenError); + this.logger.error(`Token exchange failed: ${errorMessage}`); + + // Check if error message contains the "unexpected JWT claim" text + if (errorMessage.includes('unexpected JWT claim value encountered')) { + this.logger.error( + `unexpected JWT claim value encountered during token validation by openid-client` + ); + this.logger.debug( + `Token exchange error details: ${JSON.stringify(tokenError, null, 2)}` + ); + + // Log the actual vs expected issuer + this.logger.error( + `This error typically means the 'iss' claim in the JWT doesn't match the expected issuer` + ); + this.logger.error(`Check that your provider's issuer URL is configured correctly`); + } + + throw tokenError; + } + + // Parse ID token to get user info + let claims: JwtClaims | null = null; + if (tokens.id_token) { + try { + // Use jose to properly decode the JWT + claims = decodeJwt(tokens.id_token) as JwtClaims; + + // Log claims safely without PII - only structure, not values + if (claims) { + const claimKeys = Object.keys(claims).join(', '); + this.logger.debug( + `ID token decoded successfully. Available claims: [${claimKeys}]` + ); + + // Log claim types without exposing sensitive values + for (const [key, value] of Object.entries(claims)) { + const valueType = Array.isArray(value) + ? `array[${value.length}]` + : typeof value; + + // Only log structure, not actual values (avoid PII) + this.logger.debug(`Claim '${key}': type=${valueType}`); + + // Check for unexpected claim types + if (valueType === 'object' && value !== null && !Array.isArray(value)) { + this.logger.warn(`Claim '${key}' contains complex object structure`); + } + } + } + } catch (e) { + this.logger.warn(`Failed to parse ID token: ${e}`); + } + } else { + this.logger.error('No ID token received from provider'); + } + + if (!claims?.sub) { + this.logger.error( + 'No subject in token - claims available: ' + + (claims ? Object.keys(claims).join(', ') : 'none') + ); + throw new UnauthorizedException('No subject in token'); + } + + const userSub = claims.sub; + this.logger.debug(`Processing authentication for user: ${userSub}`); + + // Check authorization based on rules + // This will throw a helpful error if misconfigured or unauthorized + await this.checkAuthorization(provider, claims); + + // Create session and return padded token + const paddedToken = await this.sessionService.createSession(providerId, userSub); + + this.logger.log(`Successfully authenticated user ${userSub} via provider ${providerId}`); + + return paddedToken; + } catch (error) { + this.logger.error( + `OAuth callback error: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + // Re-throw the original error if it's already an UnauthorizedException + if (error instanceof UnauthorizedException) { + throw error; + } + // Otherwise throw a generic error + throw new UnauthorizedException('Authentication failed'); + } + } + + private async getOrCreateConfig(provider: OidcProvider): Promise { + const cacheKey = provider.id; + + if (this.configCache.has(cacheKey)) { + return this.configCache.get(cacheKey)!; + } + + try { + // Use the validation service to perform discovery with HTTP support + if (provider.issuer) { + this.logger.debug(`Attempting discovery for ${provider.id} at ${provider.issuer}`); + + // Create client options with HTTP support if needed + const serverUrl = new URL(provider.issuer); + let clientOptions: any = undefined; + if (serverUrl.protocol === 'http:') { + this.logger.debug(`Allowing HTTP for ${provider.id} as specified by user`); + clientOptions = { + execute: [client.allowInsecureRequests], + }; + } + + try { + const config = await this.validationService.performDiscovery( + provider, + clientOptions + ); + this.logger.debug(`Discovery successful for ${provider.id}`); + this.logger.debug( + `Authorization endpoint: ${config.serverMetadata().authorization_endpoint}` + ); + this.logger.debug(`Token endpoint: ${config.serverMetadata().token_endpoint}`); + this.configCache.set(cacheKey, config); + return config; + } catch (discoveryError) { + const errorMessage = + discoveryError instanceof Error ? discoveryError.message : 'Unknown error'; + this.logger.warn(`Discovery failed for ${provider.id}: ${errorMessage}`); + + // Log more details about the discovery error + this.logger.debug( + `Discovery URL attempted: ${provider.issuer}/.well-known/openid-configuration` + ); + this.logger.debug( + `Full discovery error: ${JSON.stringify(discoveryError, null, 2)}` + ); + + // Log stack trace for better debugging + if (discoveryError instanceof Error && discoveryError.stack) { + this.logger.debug(`Stack trace: ${discoveryError.stack}`); + } + + // If discovery fails but we have manual endpoints, use them + if (provider.authorizationEndpoint && provider.tokenEndpoint) { + this.logger.log(`Using manual endpoints for ${provider.id}`); + + // Create manual configuration + const serverMetadata: client.ServerMetadata = { + issuer: provider.issuer || `manual-${provider.id}`, + authorization_endpoint: provider.authorizationEndpoint, + token_endpoint: provider.tokenEndpoint, + jwks_uri: provider.jwksUri, + }; + + const clientMetadata: Partial = { + client_secret: provider.clientSecret, + }; + + // Configure client auth method + const clientAuth = provider.clientSecret + ? client.ClientSecretPost(provider.clientSecret) + : client.None(); + + try { + const config = new client.Configuration( + serverMetadata, + provider.clientId, + clientMetadata, + clientAuth + ); + + // Use manual configuration with HTTP support if needed + const serverUrl = new URL(provider.tokenEndpoint); + if (serverUrl.protocol === 'http:') { + this.logger.debug( + `Allowing HTTP for manual endpoints on ${provider.id}` + ); + client.allowInsecureRequests(config); + } + + this.logger.debug(`Manual configuration created for ${provider.id}`); + this.logger.debug( + `Authorization endpoint: ${serverMetadata.authorization_endpoint}` + ); + this.logger.debug(`Token endpoint: ${serverMetadata.token_endpoint}`); + + this.configCache.set(cacheKey, config); + return config; + } catch (manualConfigError) { + this.logger.error( + `Failed to create manual configuration: ${manualConfigError instanceof Error ? manualConfigError.message : 'Unknown error'}` + ); + throw new Error(`Manual configuration failed for ${provider.id}`); + } + } else { + throw new Error( + `OIDC discovery failed and no manual endpoints provided for ${provider.id}` + ); + } + } + } + + // Manual configuration when no issuer is provided + if (provider.authorizationEndpoint && provider.tokenEndpoint) { + this.logger.log(`Using manual endpoints for ${provider.id} (no issuer provided)`); + + // Create manual configuration + const serverMetadata: client.ServerMetadata = { + issuer: provider.issuer || `manual-${provider.id}`, + authorization_endpoint: provider.authorizationEndpoint, + token_endpoint: provider.tokenEndpoint, + jwks_uri: provider.jwksUri, + }; + + const clientMetadata: Partial = { + client_secret: provider.clientSecret, + }; + + // Configure client auth method + const clientAuth = provider.clientSecret + ? client.ClientSecretPost(provider.clientSecret) + : client.None(); + + try { + const config = new client.Configuration( + serverMetadata, + provider.clientId, + clientMetadata, + clientAuth + ); + + // Use manual configuration with HTTP support if needed + const serverUrl = new URL(provider.tokenEndpoint); + if (serverUrl.protocol === 'http:') { + this.logger.debug(`Allowing HTTP for manual endpoints on ${provider.id}`); + client.allowInsecureRequests(config); + } + + this.logger.debug(`Manual configuration created for ${provider.id}`); + this.logger.debug( + `Authorization endpoint: ${serverMetadata.authorization_endpoint}` + ); + this.logger.debug(`Token endpoint: ${serverMetadata.token_endpoint}`); + + this.configCache.set(cacheKey, config); + return config; + } catch (manualConfigError) { + this.logger.error( + `Failed to create manual configuration: ${manualConfigError instanceof Error ? manualConfigError.message : 'Unknown error'}` + ); + throw new Error(`Manual configuration failed for ${provider.id}`); + } + } + + // If we reach here, neither discovery nor manual endpoints are available + throw new Error( + `No configuration method available for ${provider.id}: requires either valid issuer for discovery or manual endpoints` + ); + } catch (error) { + this.logger.error( + `Failed to create OIDC configuration for ${provider.id}: ${ + error instanceof Error ? error.message : 'Unknown error' + }` + ); + + // Log more details in debug mode + if (error instanceof Error && error.stack) { + this.logger.debug(`Stack trace: ${error.stack}`); + } + + throw new UnauthorizedException('Provider configuration error'); + } + } + + private async checkAuthorization(provider: OidcProvider, claims: JwtClaims): Promise { + this.logger.debug( + `Checking authorization for provider ${provider.id} with ${provider.authorizationRules?.length || 0} rules` + ); + this.logger.debug(`Available claims: ${Object.keys(claims).join(', ')}`); + this.logger.debug( + `Authorization rule mode: ${provider.authorizationRuleMode || AuthorizationRuleMode.OR}` + ); + + // If no authorization rules are specified, throw a helpful error + if (!provider.authorizationRules || provider.authorizationRules.length === 0) { + throw new UnauthorizedException( + `Login failed: The ${provider.name} provider has no authorization rules configured. ` + + `Please configure authorization rules.` + ); + } + + this.logger.debug( + `Authorization rules to evaluate: ${JSON.stringify(provider.authorizationRules, null, 2)}` + ); + + // Evaluate the rules + const ruleMode = provider.authorizationRuleMode || AuthorizationRuleMode.OR; + const isAuthorized = this.evaluateAuthorizationRules( + provider.authorizationRules, + claims, + ruleMode + ); + + this.logger.debug(`Authorization result: ${isAuthorized}`); + + if (!isAuthorized) { + // Log authorization failure with safe claim representation (no PII) + const availableClaimKeys = Object.keys(claims).join(', '); + this.logger.warn( + `Authorization failed for provider ${provider.name}, user ${claims.sub}, available claim keys: [${availableClaimKeys}]` + ); + throw new UnauthorizedException( + `Access denied: Your account does not meet the authorization requirements for ${provider.name}.` + ); + } + + this.logger.debug(`Authorization successful for user ${claims.sub}`); + } + + private evaluateAuthorizationRules( + rules: OidcAuthorizationRule[], + claims: JwtClaims, + mode: AuthorizationRuleMode = AuthorizationRuleMode.OR + ): boolean { + // No rules means no authorization + if (rules.length === 0) { + return false; + } + + if (mode === AuthorizationRuleMode.AND) { + // All rules must pass (AND logic) + return rules.every((rule) => this.evaluateRule(rule, claims)); + } else { + // Any rule can pass (OR logic) - default behavior + // Multiple rules act as alternative authorization paths + return rules.some((rule) => this.evaluateRule(rule, claims)); + } + } + + private evaluateRule(rule: OidcAuthorizationRule, claims: JwtClaims): boolean { + const claimValue = claims[rule.claim]; + + this.logger.debug( + `Evaluating rule for claim ${rule.claim}: ${JSON.stringify({ + claimValue, + claimType: typeof claimValue, + ruleOperator: rule.operator, + ruleValues: rule.value, + })}` + ); + + if (claimValue === undefined || claimValue === null) { + this.logger.debug(`Claim ${rule.claim} not found in token`); + return false; + } + + // Log detailed claim analysis + if (typeof claimValue === 'object' && claimValue !== null) { + this.logger.warn( + `unexpected JWT claim value encountered - claim ${rule.claim} is object type: ${JSON.stringify(claimValue)}` + ); + return false; + } + + if (Array.isArray(claimValue)) { + this.logger.warn( + `unexpected JWT claim value encountered - claim ${rule.claim} is array type: ${JSON.stringify(claimValue)}` + ); + return false; + } + + const value = String(claimValue); + this.logger.debug(`Processing claim ${rule.claim} with string value: "${value}"`); + + let result: boolean; + switch (rule.operator) { + case AuthorizationOperator.EQUALS: + result = rule.value.some((v) => value === v); + this.logger.debug( + `EQUALS check: "${value}" matches any of [${rule.value.join(', ')}]: ${result}` + ); + return result; + + case AuthorizationOperator.CONTAINS: + result = rule.value.some((v) => value.includes(v)); + this.logger.debug( + `CONTAINS check: "${value}" contains any of [${rule.value.join(', ')}]: ${result}` + ); + return result; + + case AuthorizationOperator.STARTS_WITH: + result = rule.value.some((v) => value.startsWith(v)); + this.logger.debug( + `STARTS_WITH check: "${value}" starts with any of [${rule.value.join(', ')}]: ${result}` + ); + return result; + + case AuthorizationOperator.ENDS_WITH: + result = rule.value.some((v) => value.endsWith(v)); + this.logger.debug( + `ENDS_WITH check: "${value}" ends with any of [${rule.value.join(', ')}]: ${result}` + ); + return result; + + default: + this.logger.error(`Unknown authorization operator: ${rule.operator}`); + return false; + } + } + + /** + * Validate OIDC provider configuration by attempting discovery + * Returns validation result with helpful error messages for debugging + */ + async validateProvider( + provider: OidcProvider + ): Promise<{ isValid: boolean; error?: string; details?: unknown }> { + // Clear any cached config for this provider to force fresh validation + this.configCache.delete(provider.id); + + // Delegate to the validation service + return this.validationService.validateProvider(provider); + } + + private getRedirectUri(requestHost?: string): string { + // Always use the proxied path through /graphql to match production + if (requestHost && requestHost.includes('localhost')) { + // In development, use the Nuxt proxy at port 3000 + return `http://localhost:3000/graphql/api/auth/oidc/callback`; + } + + // In production, use the actual request host or configured base URL + if (requestHost) { + // Parse the host to handle port numbers properly + const isLocalhost = requestHost.includes('localhost'); + const protocol = isLocalhost ? 'http' : 'https'; + + // Remove standard ports (:443 for HTTPS, :80 for HTTP) + let cleanHost = requestHost; + if (!isLocalhost) { + if (requestHost.endsWith(':443')) { + cleanHost = requestHost.slice(0, -4); // Remove :443 + } else if (requestHost.endsWith(':80')) { + cleanHost = requestHost.slice(0, -3); // Remove :80 + } + } + + return `${protocol}://${cleanHost}/graphql/api/auth/oidc/callback`; + } + + // Fall back to configured BASE_URL or default + const baseUrl = this.configService.get('BASE_URL', 'http://tower.local'); + return `${baseUrl}/graphql/api/auth/oidc/callback`; + } +} diff --git a/api/src/unraid-api/graph/resolvers/sso/oidc-config.service.ts b/api/src/unraid-api/graph/resolvers/sso/oidc-config.service.ts new file mode 100644 index 000000000..d4ffee178 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/sso/oidc-config.service.ts @@ -0,0 +1,1098 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { RuleEffect } from '@jsonforms/core'; +import { mergeSettingSlices } from '@unraid/shared/jsonforms/settings.js'; +import { ConfigFilePersister } from '@unraid/shared/services/config-file.js'; +import { UserSettingsService } from '@unraid/shared/services/user-settings.js'; + +import { + AuthorizationOperator, + OidcAuthorizationRule, + OidcProvider, +} from '@app/unraid-api/graph/resolvers/sso/oidc-provider.model.js'; +import { OidcValidationService } from '@app/unraid-api/graph/resolvers/sso/oidc-validation.service.js'; +import { + createAccordionLayout, + createLabeledControl, + createSimpleLabeledControl, +} from '@app/unraid-api/graph/utils/form-utils.js'; +import { SettingSlice } from '@app/unraid-api/types/json-forms.js'; + +export interface OidcConfig { + providers: OidcProvider[]; +} + +@Injectable() +export class OidcConfigPersistence extends ConfigFilePersister { + constructor( + configService: ConfigService, + private readonly userSettings: UserSettingsService, + private readonly validationService: OidcValidationService + ) { + super(configService); + this.registerSettings(); + } + + fileName(): string { + // Check for environment variable override + const envPath = process.env.PATHS_OIDC_JSON; + if (envPath) { + // Extract just the filename from the path + const parts = envPath.split('/'); + return parts[parts.length - 1]; + } + return 'oidc.json'; + } + + configKey(): string { + return 'oidc'; + } + + defaultConfig(): OidcConfig { + return { + providers: [this.getUnraidNetSsoProvider()], + }; + } + + private getUnraidNetSsoProvider(): OidcProvider { + return { + id: 'unraid.net', + name: 'Unraid.net', + clientId: 'CONNECT_SERVER_SSO', + issuer: 'https://account.unraid.net', + scopes: ['openid', 'profile', 'email'], + authorizationRules: [], + buttonText: 'Login With Unraid.net', + buttonIcon: + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMzMuNTIgNzYuOTciPjx0aXRsZT5VTi1tYXJrLXdoaXRlPC90aXRsZT48cGF0aCBkPSJNNjMuNDksMTkuMjRINzBWNTcuNzNINjMuNDlaTTYuNTQsNTcuNzNIMFYxOS4yNEg2LjU0Wm0yNS4yLDQuNTRoNi41NVY3N0gzMS43NFpNMTUuODcsNDUuODRoNi41NFY2OS42MkgxNS44N1ptMzEuNzUsMGg2LjU0VjY5LjYySDQ3LjYyWk0xMjcsMTkuMjRoNi41NFY1Ny43M0gxMjdaTTEwMS43NywxNC43SDk1LjIzVjBoNi41NFptMTUuODgsMTYuNDRIMTExLjFWNy4zNWg2LjU1Wm0tMzEuNzUsMEg3OS4zNlY3LjM1SDg1LjlaIiBmaWxsPSIjZmZmIi8+PC9zdmc+', + buttonVariant: 'primary', + buttonStyle: + 'background-color: #ff6600; border-color: #ff6600; color: white; transition: all 0.2s;', + }; + } + + async migrateConfig(): Promise { + // Get existing SSO users from the main config + const ssoSubIds = this.configService.get('api.ssoSubIds', []); + + // Always ensure Unraid.net SSO provider is present with migrated users + const unraidNetSsoProvider = this.getUnraidNetSsoProvider(); + + // Convert legacy authorizedSubIds to authorization rules + if (ssoSubIds.length > 0) { + unraidNetSsoProvider.authorizationRules = [ + { + claim: 'sub', + operator: AuthorizationOperator.EQUALS, + value: ssoSubIds, + }, + ]; + this.logger.log(`Migrated ${ssoSubIds.length} SSO users to authorization rules`); + } + + return { + providers: [unraidNetSsoProvider], + }; + } + + async getProviders(): Promise { + const config = this.configService.get(this.configKey()); + const providers = config?.providers || []; + + // Ensure unraid.net provider is always present with current defaults + const hasUnraidNet = providers.some((p) => p.id === 'unraid.net'); + if (!hasUnraidNet) { + providers.unshift(this.getUnraidNetSsoProvider()); + } + + // Ensure unraid.net provider always has current defaults while preserving authorization rules + const updatedProviders = providers.map((provider) => { + if (provider.id === 'unraid.net') { + const currentDefaults = this.getUnraidNetSsoProvider(); + // Preserve existing authorization rules but override UI/button properties + return { + ...provider, + ...currentDefaults, + // Keep existing authorization rules if they exist + authorizationRules: + provider.authorizationRules || currentDefaults.authorizationRules, + }; + } + return provider; + }); + + // Clean up providers - convert empty strings to undefined + return updatedProviders.map((provider) => this.cleanProvider(provider)); + } + + private cleanProvider(provider: OidcProvider): OidcProvider { + // Convert empty strings to undefined for optional fields + return { + ...provider, + clientSecret: provider.clientSecret?.trim() || undefined, + authorizationEndpoint: provider.authorizationEndpoint?.trim() || undefined, + tokenEndpoint: provider.tokenEndpoint?.trim() || undefined, + buttonIcon: provider.buttonIcon?.trim() || undefined, + buttonStyle: provider.buttonStyle?.trim() || undefined, + }; + } + + async getProvider(id: string): Promise { + const providers = await this.getProviders(); + return providers.find((p) => p.id === id) || null; + } + + async upsertProvider( + provider: OidcProvider & { authorizationMode?: string; simpleAuthorization?: any } + ): Promise { + const config = this.configService.get(this.configKey()) || this.defaultConfig(); + const providers = [...config.providers]; + + // If in simple mode, convert simple fields to authorization rules + if (provider.authorizationMode === 'simple' && provider.simpleAuthorization) { + const rules = this.convertSimpleToRules(provider.simpleAuthorization); + provider.authorizationRules = rules; + } + + // Clean up the provider object - remove UI-only fields + const cleanedProvider: OidcProvider = { + id: provider.id, + name: provider.name, + clientId: provider.clientId, + clientSecret: provider.clientSecret, + issuer: provider.issuer, + authorizationEndpoint: provider.authorizationEndpoint, + tokenEndpoint: provider.tokenEndpoint, + jwksUri: provider.jwksUri, + scopes: provider.scopes, + authorizationRules: provider.authorizationRules, + buttonText: provider.buttonText, + buttonIcon: provider.buttonIcon, + buttonVariant: provider.buttonVariant, + buttonStyle: provider.buttonStyle, + }; + + const existingIndex = providers.findIndex((p) => p.id === provider.id); + if (existingIndex >= 0) { + providers[existingIndex] = cleanedProvider; + } else { + providers.push(cleanedProvider); + } + + const newConfig = { ...config, providers }; + this.configService.set(this.configKey(), newConfig); + await this.persist(newConfig); + + return cleanedProvider; + } + + private convertSimpleToRules(simpleAuth: { + allowedDomains?: string[]; + allowedEmails?: string[]; + allowedUserIds?: string[]; + googleWorkspaceDomain?: string; + }): OidcAuthorizationRule[] { + const rules: OidcAuthorizationRule[] = []; + + // Convert email domains to endsWith rules + if (simpleAuth?.allowedDomains && simpleAuth.allowedDomains.length > 0) { + rules.push({ + claim: 'email', + operator: AuthorizationOperator.ENDS_WITH, + value: simpleAuth.allowedDomains.map((domain: string) => + domain.startsWith('@') ? domain : `@${domain}` + ), + }); + } + + // Convert specific emails to equals rules + if (simpleAuth?.allowedEmails && simpleAuth.allowedEmails.length > 0) { + rules.push({ + claim: 'email', + operator: AuthorizationOperator.EQUALS, + value: simpleAuth.allowedEmails, + }); + } + + // Convert user IDs to sub equals rules + if (simpleAuth?.allowedUserIds && simpleAuth.allowedUserIds.length > 0) { + rules.push({ + claim: 'sub', + operator: AuthorizationOperator.EQUALS, + value: simpleAuth.allowedUserIds, + }); + } + + // Google Workspace domain (hd claim) + if (simpleAuth?.googleWorkspaceDomain) { + rules.push({ + claim: 'hd', + operator: AuthorizationOperator.EQUALS, + value: [simpleAuth.googleWorkspaceDomain], + }); + } + + return rules; + } + + async deleteProvider(id: string): Promise { + // Prevent deletion of the unraid.net provider + if (id === 'unraid.net') { + this.logger.warn(`Attempted to delete protected provider: ${id}`); + return false; + } + + const config = this.configService.get(this.configKey()) || this.defaultConfig(); + const filteredProviders = config.providers.filter((p) => p.id !== id); + + if (filteredProviders.length === config.providers.length) { + return false; + } + + const newConfig = { ...config, providers: filteredProviders }; + this.configService.set(this.configKey(), newConfig); + await this.persist(newConfig); + + return true; + } + + private registerSettings() { + this.userSettings.register('sso', { + buildSlice: async () => this.buildSlice(), + getCurrentValues: async () => this.getConfig(), + updateValues: async ( + config: OidcConfig & { + providers: Array< + OidcProvider & { authorizationMode?: string; simpleAuthorization?: unknown } + >; + } + ) => { + // Process each provider to handle simple mode conversion + const processedConfig: OidcConfig = { + ...config, + providers: config.providers.map((provider) => { + const extendedProvider = provider as OidcProvider & { + authorizationMode?: string; + simpleAuthorization?: unknown; + }; + // If in simple mode, convert simple fields to authorization rules + if ( + extendedProvider.authorizationMode === 'simple' && + extendedProvider.simpleAuthorization + ) { + const rules = this.convertSimpleToRules( + extendedProvider.simpleAuthorization as { + allowedDomains?: string[]; + allowedEmails?: string[]; + allowedUserIds?: string[]; + googleWorkspaceDomain?: string; + } + ); + // Return provider with generated rules, removing UI-only fields + const { authorizationMode, simpleAuthorization, ...cleanProvider } = + extendedProvider; + return { + ...cleanProvider, + authorizationRules: rules, + }; + } + // If in advanced mode or no mode specified, just clean up UI fields + const { authorizationMode, simpleAuthorization, ...cleanProvider } = + extendedProvider; + return cleanProvider; + }), + }; + + // Validate OIDC discovery for all providers with issuer URLs + const validationErrors: string[] = []; + for (const provider of processedConfig.providers) { + if (provider.issuer) { + try { + // Parse the issuer URL and check if hostname is exactly 'unraid.net' + const issuerUrl = new URL(provider.issuer); + if (issuerUrl.hostname === 'unraid.net') { + // Skip validation for unraid.net as it uses custom auth flow + continue; + } + } catch (urlError) { + // Invalid URL, proceed with validation + } + + try { + const validation = await this.validationService.validateProvider(provider); + if (!validation.isValid) { + validationErrors.push(`❌ ${provider.name}: ${validation.error}`); + } + } catch (error) { + // Don't fail the save, just warn + this.logger.warn(`Failed to validate provider ${provider.id}: ${error}`); + } + } + } + + this.configService.set(this.configKey(), processedConfig); + await this.persist(processedConfig); + + // Include validation results in response + const response: { restartRequired: boolean; values: OidcConfig; warnings?: string[] } = { + restartRequired: false, + values: processedConfig, + }; + + if (validationErrors.length > 0) { + response.warnings = [ + '⚠️ OIDC Discovery Issues Found:', + '', + ...validationErrors, + '', + '💡 These providers may not work properly. Please check your configuration.', + 'Note: Configuration has been saved, but you should fix these issues before testing login.', + ]; + } + + return response; + }, + }); + } + + getConfig(): OidcConfig & { + providers: Array< + OidcProvider & { + authorizationMode?: string; + simpleAuthorization?: unknown; + isProtected?: boolean; + } + >; + } { + const config = this.configService.get(this.configKey()) || this.defaultConfig(); + + // Ensure unraid.net provider always has current defaults while preserving authorization rules + const providers = config.providers.map((provider) => { + if (provider.id === 'unraid.net') { + const currentDefaults = this.getUnraidNetSsoProvider(); + // Preserve existing authorization rules but override UI/button properties + return { + ...provider, + ...currentDefaults, + // Keep existing authorization rules if they exist + authorizationRules: + provider.authorizationRules || currentDefaults.authorizationRules, + }; + } + return provider; + }); + + // Enhance providers with UI fields + const enhancedProviders = providers.map((provider) => { + const simpleAuth = this.convertRulesToSimple(provider.authorizationRules || []); + + // Determine if rules can be represented in simple mode + const canUseSimpleMode = this.canConvertToSimpleMode(provider.authorizationRules || []); + + return { + ...provider, + authorizationMode: canUseSimpleMode ? 'simple' : 'advanced', + simpleAuthorization: simpleAuth, + isProtected: provider.id === 'unraid.net', // Mark unraid.net as protected + }; + }); + + return { + ...config, + providers: enhancedProviders, + }; + } + + private canConvertToSimpleMode(rules: OidcAuthorizationRule[]): boolean { + // Check if all rules match simple patterns + return rules.every((rule) => { + // Email domain rules + if (rule.claim === 'email' && rule.operator === AuthorizationOperator.ENDS_WITH) { + return rule.value.every((v) => v.startsWith('@')); + } + // Email equals rules + if (rule.claim === 'email' && rule.operator === AuthorizationOperator.EQUALS) { + return true; + } + // Sub equals rules + if (rule.claim === 'sub' && rule.operator === AuthorizationOperator.EQUALS) { + return true; + } + // Google Workspace domain + if (rule.claim === 'hd' && rule.operator === AuthorizationOperator.EQUALS) { + return true; + } + return false; + }); + } + + private convertRulesToSimple(rules: OidcAuthorizationRule[]): { + allowedDomains: string[]; + allowedEmails: string[]; + allowedUserIds: string[]; + googleWorkspaceDomain?: string; + } { + const simpleAuth = { + allowedDomains: [] as string[], + allowedEmails: [] as string[], + allowedUserIds: [] as string[], + googleWorkspaceDomain: undefined as string | undefined, + }; + + rules.forEach((rule) => { + if (rule.claim === 'email' && rule.operator === AuthorizationOperator.ENDS_WITH) { + simpleAuth.allowedDomains = rule.value.map((v) => + v.startsWith('@') ? v.substring(1) : v + ); + } else if (rule.claim === 'email' && rule.operator === AuthorizationOperator.EQUALS) { + simpleAuth.allowedEmails = rule.value; + } else if (rule.claim === 'sub' && rule.operator === AuthorizationOperator.EQUALS) { + simpleAuth.allowedUserIds = rule.value; + } else if ( + rule.claim === 'hd' && + rule.operator === AuthorizationOperator.EQUALS && + rule.value.length > 0 + ) { + simpleAuth.googleWorkspaceDomain = rule.value[0]; + } + }); + + return simpleAuth; + } + + private buildSlice(): SettingSlice { + return mergeSettingSlices([this.oidcProvidersSlice()], { as: 'sso' }); + } + + private oidcProvidersSlice(): SettingSlice { + return { + properties: { + providers: { + type: 'array', + items: { + type: 'object', + properties: { + id: { + type: 'string', + title: 'Provider ID', + description: 'Unique identifier for the provider', + pattern: '^[a-zA-Z0-9._-]+$', + }, + name: { + type: 'string', + title: 'Provider Name', + description: 'Display name for the provider', + }, + clientId: { + type: 'string', + title: 'Client ID', + description: 'OAuth2 client ID registered with the provider', + }, + clientSecret: { + type: 'string', + title: 'Client Secret', + description: 'OAuth2 client secret (if required)', + }, + issuer: { + type: 'string', + title: 'Issuer URL', + format: 'uri', + description: 'OIDC issuer URL (e.g., https://accounts.google.com)', + }, + authorizationEndpoint: { + anyOf: [ + { type: 'string', minLength: 1, format: 'uri' }, + { type: 'string', maxLength: 0 }, + ], + title: 'Authorization Endpoint', + description: 'Optional - will be auto-discovered if not provided', + }, + tokenEndpoint: { + anyOf: [ + { type: 'string', minLength: 1, format: 'uri' }, + { type: 'string', maxLength: 0 }, + ], + title: 'Token Endpoint', + description: 'Optional - will be auto-discovered if not provided', + }, + jwksUri: { + anyOf: [ + { type: 'string', minLength: 1, format: 'uri' }, + { type: 'string', maxLength: 0 }, + ], + title: 'JWKS URI', + description: 'Optional - will be auto-discovered if not provided', + }, + scopes: { + type: 'array', + items: { type: 'string' }, + title: 'Scopes', + default: ['openid', 'profile', 'email'], + description: 'OAuth2 scopes to request', + }, + authorizationMode: { + type: 'string', + title: 'Authorization Mode', + enum: ['simple', 'advanced'], + default: 'simple', + description: + 'Choose between simple presets or advanced rule configuration', + }, + simpleAuthorization: { + type: 'object', + properties: { + allowedDomains: { + type: 'array', + items: { type: 'string' }, + title: 'Allowed Email Domains', + description: + 'Email domains that are allowed to login (e.g., company.com)', + }, + allowedEmails: { + type: 'array', + items: { type: 'string' }, + title: 'Specific Email Addresses', + description: + 'Specific email addresses that are allowed to login', + }, + allowedUserIds: { + type: 'array', + items: { type: 'string' }, + title: 'Allowed User IDs', + description: + 'Specific user IDs (sub claim) that are allowed to login', + }, + googleWorkspaceDomain: { + type: 'string', + title: 'Google Workspace Domain', + description: + 'Restrict to users from a specific Google Workspace domain', + }, + }, + }, + authorizationRuleMode: { + type: 'string', + title: 'Rule Mode', + enum: ['or', 'and'], + default: 'or', + description: + 'How to evaluate multiple rules: OR (any rule passes) or AND (all rules must pass)', + }, + authorizationRules: { + type: 'array', + items: { + type: 'object', + properties: { + claim: { + type: 'string', + title: 'Claim', + description: 'JWT claim to check', + }, + operator: { + type: 'string', + title: 'Operator', + enum: ['equals', 'contains', 'endsWith', 'startsWith'], + }, + value: { + type: 'array', + items: { type: 'string' }, + title: 'Values', + description: 'Values to match against', + }, + }, + required: ['claim', 'operator', 'value'], + }, + title: 'Claim Rules', + description: + 'Define authorization rules based on claims in the ID token. Rule mode can be configured: OR logic (any rule matches) or AND logic (all rules must match).', + }, + buttonText: { + type: 'string', + title: 'Button Text', + description: 'Custom text for the login button', + }, + buttonIcon: { + anyOf: [ + { type: 'string', minLength: 1 }, + { type: 'string', maxLength: 0 }, + ], + title: 'Button Icon URL', + description: 'URL or base64 encoded icon for the login button', + }, + buttonVariant: { + type: 'string', + title: 'Button Style', + enum: [ + 'primary', + 'destructive', + 'outline', + 'secondary', + 'ghost', + 'link', + ], + description: 'Visual style of the login button', + default: 'outline', + }, + buttonStyle: { + type: 'string', + title: 'Custom CSS Styles', + description: + 'Custom inline CSS styles for the button (e.g., "background: linear-gradient(to right, #4f46e5, #7c3aed); border-radius: 9999px;")', + }, + }, + required: ['id', 'name', 'clientId', 'issuer'], + }, + title: 'OIDC Providers', + description: 'Configure OpenID Connect providers for SSO authentication', + }, + }, + elements: [ + { + type: 'VerticalLayout', + elements: [ + { + type: 'Label', + text: 'OIDC Providers', + options: { + format: 'title', + }, + }, + { + type: 'Control', + scope: '#/properties/sso/properties/providers', + options: { + elementLabelProp: 'name', + itemTypeName: 'Provider', + protectedItems: [{ field: 'id', value: 'unraid.net' }], + itemWarnings: [ + { + condition: { field: 'id', value: 'unraid.net' }, + title: 'Unraid.net Provider', + description: + 'This is the built-in Unraid.net provider. Only authorization rules can be modified.', + }, + ], + detail: createAccordionLayout({ + defaultOpen: [0], + elements: [ + { + type: 'VerticalLayout', + options: { + accordion: { + title: 'Basic Configuration', + description: 'Essential provider settings', + }, + }, + rule: { + effect: RuleEffect.HIDE, + condition: { + scope: '#/properties/id', + schema: { const: 'unraid.net' }, + }, + }, + elements: [ + createSimpleLabeledControl({ + scope: '#/properties/id', + label: 'Provider ID:', + description: + 'Unique identifier (e.g., google, github)', + controlOptions: { + inputType: 'text', + placeholder: 'provider-id', + }, + rule: { + effect: RuleEffect.HIDE, + condition: { + scope: '#/properties/id', + schema: { const: 'unraid.net' }, + }, + }, + }), + createSimpleLabeledControl({ + scope: '#/properties/name', + label: 'Provider Name:', + description: 'Display name for users', + controlOptions: { + inputType: 'text', + placeholder: 'My Provider', + }, + rule: { + effect: RuleEffect.HIDE, + condition: { + scope: '#/properties/id', + schema: { const: 'unraid.net' }, + }, + }, + }), + createSimpleLabeledControl({ + scope: '#/properties/clientId', + label: 'Client ID:', + description: 'OAuth2 application client ID', + controlOptions: { + inputType: 'text', + }, + rule: { + effect: RuleEffect.HIDE, + condition: { + scope: '#/properties/id', + schema: { const: 'unraid.net' }, + }, + }, + }), + createSimpleLabeledControl({ + scope: '#/properties/clientSecret', + label: 'Client Secret:', + description: + 'OAuth2 application client secret (optional)', + controlOptions: { + inputType: 'password', + }, + rule: { + effect: RuleEffect.HIDE, + condition: { + scope: '#/properties/id', + schema: { const: 'unraid.net' }, + }, + }, + }), + createSimpleLabeledControl({ + scope: '#/properties/issuer', + label: 'Issuer URL:', + description: 'OIDC issuer/discovery URL', + controlOptions: { + inputType: 'url', + placeholder: 'https://accounts.google.com', + }, + rule: { + effect: RuleEffect.HIDE, + condition: { + scope: '#/properties/id', + schema: { const: 'unraid.net' }, + }, + }, + }), + createSimpleLabeledControl({ + scope: '#/properties/scopes', + label: 'OAuth Scopes:', + description: 'Scopes to request from the provider', + controlOptions: { + format: 'array', + inputType: 'text', + placeholder: 'openid', + }, + rule: { + effect: RuleEffect.HIDE, + condition: { + scope: '#/properties/id', + schema: { const: 'unraid.net' }, + }, + }, + }), + ], + }, + { + type: 'VerticalLayout', + options: { + accordion: { + title: 'Advanced Endpoints', + description: + 'Override auto-discovery settings (optional)', + }, + }, + rule: { + effect: RuleEffect.HIDE, + condition: { + scope: '#/properties/id', + schema: { const: 'unraid.net' }, + }, + }, + elements: [ + createSimpleLabeledControl({ + scope: '#/properties/authorizationEndpoint', + label: 'Authorization Endpoint:', + description: 'Override auto-discovery (optional)', + controlOptions: { + inputType: 'url', + }, + rule: { + effect: RuleEffect.HIDE, + condition: { + scope: '#/properties/id', + schema: { const: 'unraid.net' }, + }, + }, + }), + createSimpleLabeledControl({ + scope: '#/properties/tokenEndpoint', + label: 'Token Endpoint:', + description: 'Override auto-discovery (optional)', + controlOptions: { + inputType: 'url', + }, + rule: { + effect: RuleEffect.HIDE, + condition: { + scope: '#/properties/id', + schema: { const: 'unraid.net' }, + }, + }, + }), + createSimpleLabeledControl({ + scope: '#/properties/jwksUri', + label: 'JWKS URI:', + description: 'Override auto-discovery (optional)', + controlOptions: { + inputType: 'url', + }, + rule: { + effect: RuleEffect.HIDE, + condition: { + scope: '#/properties/id', + schema: { const: 'unraid.net' }, + }, + }, + }), + ], + }, + { + type: 'VerticalLayout', + options: { + accordion: { + title: 'Authorization Rules', + description: 'Configure who can access your server', + }, + }, + elements: [ + // Authorization Mode Toggle + createSimpleLabeledControl({ + scope: '#/properties/authorizationMode', + label: 'Authorization Mode:', + description: + 'Choose between simple presets or advanced rule configuration', + controlOptions: {}, + }), + // Simple Authorization Fields (shown when mode is 'simple') + { + type: 'VerticalLayout', + rule: { + effect: RuleEffect.SHOW, + condition: { + scope: '#/properties/authorizationMode', + schema: { const: 'simple' }, + }, + }, + elements: [ + { + type: 'Label', + text: 'Simple Authorization', + options: { + description: + 'Configure who can login using simple presets. At least one field must be configured.', + format: 'title', + }, + }, + createSimpleLabeledControl({ + scope: '#/properties/simpleAuthorization/properties/allowedDomains', + label: 'Allowed Email Domains:', + description: + 'Users with emails ending in these domains can login (e.g., company.com)', + controlOptions: { + format: 'array', + inputType: 'text', + placeholder: 'company.com', + }, + }), + createSimpleLabeledControl({ + scope: '#/properties/simpleAuthorization/properties/allowedEmails', + label: 'Specific Email Addresses:', + description: + 'Only these exact email addresses can login', + controlOptions: { + format: 'array', + inputType: 'email', + placeholder: 'user@example.com', + }, + }), + createSimpleLabeledControl({ + scope: '#/properties/simpleAuthorization/properties/allowedUserIds', + label: 'Allowed User IDs:', + description: + 'Specific user IDs from the identity provider', + controlOptions: { + format: 'array', + inputType: 'text', + placeholder: 'user-id-123', + }, + }), + // Google-specific field (shown only for Google providers) + { + type: 'VerticalLayout', + rule: { + effect: RuleEffect.SHOW, + condition: { + scope: '#/properties/issuer', + schema: { pattern: '.*google.*' }, + }, + }, + elements: [ + createSimpleLabeledControl({ + scope: '#/properties/simpleAuthorization/properties/googleWorkspaceDomain', + label: 'Google Workspace Domain:', + description: + 'Restrict to users from your Google Workspace domain', + controlOptions: { + inputType: 'text', + placeholder: 'company.com', + }, + }), + ], + }, + ], + }, + // Advanced Authorization Rules (shown when mode is 'advanced' or authorizationRuleMode is 'and') + { + type: 'VerticalLayout', + rule: { + effect: RuleEffect.SHOW, + condition: { + type: 'OR', + conditions: [ + { + scope: '#/properties/authorizationMode', + schema: { const: 'advanced' }, + }, + { + scope: '#/properties/authorizationRuleMode', + schema: { const: 'AND' }, + }, + ], + }, + }, + elements: [ + { + type: 'Label', + text: 'Advanced Authorization Rules', + options: { + description: + 'Define authorization rules based on claims in the ID token. Rule mode can be configured: OR logic (any rule matches) or AND logic (all rules must match).', + }, + }, + createSimpleLabeledControl({ + scope: '#/properties/authorizationRuleMode', + label: 'Rule Mode:', + description: + 'How to evaluate multiple rules: OR (any rule passes) or AND (all rules must pass)', + controlOptions: {}, + }), + { + type: 'Control', + scope: '#/properties/authorizationRules', + options: { + elementLabelFormat: + '${claim} ${operator}', + itemTypeName: 'Rule', + detail: { + type: 'VerticalLayout', + elements: [ + createSimpleLabeledControl({ + scope: '#/properties/claim', + label: 'JWT Claim:', + description: + 'JWT claim to check (e.g., email, sub, groups, hd for Google hosted domain)', + controlOptions: { + inputType: 'text', + placeholder: 'email', + }, + }), + createSimpleLabeledControl({ + scope: '#/properties/operator', + label: 'Operator:', + description: + 'How to compare the claim value', + controlOptions: {}, + }), + createSimpleLabeledControl({ + scope: '#/properties/value', + label: 'Values:', + description: + 'Value(s) to match against (any match passes)', + controlOptions: { + format: 'array', + inputType: 'text', + placeholder: + '@company.com', + }, + }), + ], + }, + }, + }, + ], + }, + ], + }, + { + type: 'VerticalLayout', + options: { + accordion: { + title: 'Button Customization', + description: + 'Customize the appearance of the login button', + }, + }, + rule: { + effect: RuleEffect.HIDE, + condition: { + scope: '#/properties/id', + schema: { const: 'unraid.net' }, + }, + }, + elements: [ + createSimpleLabeledControl({ + scope: '#/properties/buttonText', + label: 'Button Text:', + description: 'Custom login button text (optional)', + controlOptions: { + inputType: 'text', + placeholder: 'Sign in with Provider', + }, + }), + createSimpleLabeledControl({ + scope: '#/properties/buttonIcon', + label: 'Button Icon URL:', + description: + 'Icon URL or base64 data URI (optional)', + controlOptions: { + inputType: 'url', + }, + }), + createSimpleLabeledControl({ + scope: '#/properties/buttonVariant', + label: 'Button Style:', + description: 'Visual style of the login button', + controlOptions: {}, + }), + createSimpleLabeledControl({ + scope: '#/properties/buttonStyle', + label: 'Custom CSS Styles:', + description: + 'Inline CSS styles for custom button appearance. Buttons with background-color will automatically get hover effects (darkening on hover/active). Examples:\n\nSolid color: "background-color: #ff6600; border-color: #ff6600; color: white;"\nGradient: "background: linear-gradient(45deg, #667eea, #764ba2); border: none; color: white;"\nRounded: "border-radius: 25px; background-color: #10b981; color: white;"', + controlOptions: { + inputType: 'textarea', + placeholder: + 'background-color: #3b82f6; border-color: #3b82f6; color: white; transition: all 0.2s;', + }, + }), + ], + }, + ], + }), + }, + }, + ], + }, + ], + }; + } +} diff --git a/api/src/unraid-api/graph/resolvers/sso/oidc-provider.model.ts b/api/src/unraid-api/graph/resolvers/sso/oidc-provider.model.ts new file mode 100644 index 000000000..163b4065e --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/sso/oidc-provider.model.ts @@ -0,0 +1,168 @@ +import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; + +import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + IsUrl, + ValidateNested, +} from 'class-validator'; + +export enum AuthorizationOperator { + EQUALS = 'equals', + CONTAINS = 'contains', + ENDS_WITH = 'endsWith', + STARTS_WITH = 'startsWith', +} + +export enum AuthorizationRuleMode { + OR = 'or', + AND = 'and', +} + +registerEnumType(AuthorizationOperator, { + name: 'AuthorizationOperator', + description: 'Operators for authorization rule matching', +}); + +registerEnumType(AuthorizationRuleMode, { + name: 'AuthorizationRuleMode', + description: + 'Mode for evaluating authorization rules - OR (any rule passes) or AND (all rules must pass)', +}); + +@ObjectType() +export class OidcAuthorizationRule { + @Field(() => String, { description: 'The claim to check (e.g., email, sub, groups, hd)' }) + @IsString() + @IsNotEmpty() + claim!: string; + + @Field(() => AuthorizationOperator, { description: 'The comparison operator' }) + @IsEnum(AuthorizationOperator) + operator!: AuthorizationOperator; + + @Field(() => [String], { description: 'The value(s) to match against' }) + @IsArray() + @IsString({ each: true }) + value!: string[]; +} + +@ObjectType() +export class OidcProvider { + @Field(() => PrefixedID, { description: 'The unique identifier for the OIDC provider' }) + @IsString() + @IsNotEmpty() + id!: string; + + @Field(() => String, { description: 'Display name of the OIDC provider' }) + @IsString() + @IsNotEmpty() + name!: string; + + @Field(() => String, { description: 'OAuth2 client ID registered with the provider' }) + @IsString() + @IsNotEmpty() + clientId!: string; + + @Field(() => String, { + nullable: true, + description: 'OAuth2 client secret (if required by provider)', + }) + @IsString() + @IsOptional() + clientSecret?: string; + + @Field(() => String, { + description: + 'OIDC issuer URL (e.g., https://accounts.google.com). Required for auto-discovery via /.well-known/openid-configuration', + }) + @IsUrl() + issuer!: string; + + @Field(() => String, { + nullable: true, + description: + 'OAuth2 authorization endpoint URL. If omitted, will be auto-discovered from issuer/.well-known/openid-configuration', + }) + @IsUrl() + @IsOptional() + authorizationEndpoint?: string; + + @Field(() => String, { + nullable: true, + description: + 'OAuth2 token endpoint URL. If omitted, will be auto-discovered from issuer/.well-known/openid-configuration', + }) + @IsUrl() + @IsOptional() + tokenEndpoint?: string; + + @Field(() => String, { + nullable: true, + description: + 'JSON Web Key Set URI for token validation. If omitted, will be auto-discovered from issuer/.well-known/openid-configuration', + }) + @IsUrl() + @IsOptional() + jwksUri?: string; + + @Field(() => [String], { description: 'OAuth2 scopes to request (e.g., openid, profile, email)' }) + @IsArray() + @IsString({ each: true }) + scopes!: string[]; + + @Field(() => [OidcAuthorizationRule], { + nullable: true, + description: 'Flexible authorization rules based on claims', + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => OidcAuthorizationRule) + @IsOptional() + authorizationRules?: OidcAuthorizationRule[]; + + @Field(() => AuthorizationRuleMode, { + nullable: true, + description: + 'Mode for evaluating authorization rules - OR (any rule passes) or AND (all rules must pass). Defaults to OR.', + defaultValue: AuthorizationRuleMode.OR, + }) + @IsEnum(AuthorizationRuleMode) + @IsOptional() + authorizationRuleMode?: AuthorizationRuleMode; + + @Field(() => String, { nullable: true, description: 'Custom text for the login button' }) + @IsString() + @IsOptional() + buttonText?: string; + + @Field(() => String, { + nullable: true, + description: 'URL or base64 encoded icon for the login button', + }) + @IsString() + @IsOptional() + buttonIcon?: string; + + @Field(() => String, { + nullable: true, + description: 'Button variant style from Reka UI. See https://reka-ui.com/docs/components/button', + }) + @IsString() + @IsOptional() + buttonVariant?: string; + + @Field(() => String, { + nullable: true, + description: + 'Custom CSS styles for the button (e.g., "background: linear-gradient(to right, #4f46e5, #7c3aed); border-radius: 9999px;")', + }) + @IsString() + @IsOptional() + buttonStyle?: string; +} diff --git a/api/src/unraid-api/graph/resolvers/sso/oidc-session-validation.model.ts b/api/src/unraid-api/graph/resolvers/sso/oidc-session-validation.model.ts new file mode 100644 index 000000000..04af9c776 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/sso/oidc-session-validation.model.ts @@ -0,0 +1,10 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class OidcSessionValidation { + @Field(() => Boolean) + valid!: boolean; + + @Field(() => String, { nullable: true }) + username?: string; +} diff --git a/api/src/unraid-api/graph/resolvers/sso/oidc-session.service.spec.ts b/api/src/unraid-api/graph/resolvers/sso/oidc-session.service.spec.ts new file mode 100644 index 000000000..3fe1a448e --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/sso/oidc-session.service.spec.ts @@ -0,0 +1,121 @@ +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Test } from '@nestjs/testing'; + +import type { Cache } from 'cache-manager'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OidcSessionService } from '@app/unraid-api/graph/resolvers/sso/oidc-session.service.js'; + +describe('OidcSessionService', () => { + let service: OidcSessionService; + let cacheManager: Cache; + + beforeEach(async () => { + const mockCacheManager = { + get: vi.fn(), + set: vi.fn(), + del: vi.fn(), + }; + + const module = await Test.createTestingModule({ + providers: [ + OidcSessionService, + { + provide: CACHE_MANAGER, + useValue: mockCacheManager, + }, + ], + }).compile(); + + service = module.get(OidcSessionService); + cacheManager = module.get(CACHE_MANAGER); + }); + + describe('one-time token validation', () => { + it('should validate a token successfully on first attempt', async () => { + // Create a session + const token = await service.createSession('test-provider', 'test-user-id'); + + // Mock cache get to return the session + vi.mocked(cacheManager.get).mockResolvedValueOnce({ + id: expect.any(String), + providerId: 'test-provider', + providerUserId: 'test-user-id', + createdAt: new Date(), + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + }); + + // First validation should succeed + const result = await service.validateSession(token); + expect(result.valid).toBe(true); + expect(result.username).toBe('root'); + expect(cacheManager.del).toHaveBeenCalled(); + }); + + it('should fail validation on second attempt with same token', async () => { + // Create a session + const token = await service.createSession('test-provider', 'test-user-id'); + + // Mock cache get for first validation + vi.mocked(cacheManager.get).mockResolvedValueOnce({ + id: expect.any(String), + providerId: 'test-provider', + providerUserId: 'test-user-id', + createdAt: new Date(), + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + }); + + // First validation should succeed + const firstResult = await service.validateSession(token); + expect(firstResult.valid).toBe(true); + + // Mock cache get for second validation (session deleted) + vi.mocked(cacheManager.get).mockResolvedValueOnce(null); + + // Second validation should fail (token already used) + const secondResult = await service.validateSession(token); + expect(secondResult.valid).toBe(false); + expect(secondResult.username).toBeUndefined(); + }); + + it('should handle invalid token format', async () => { + const result = await service.validateSession('invalid-token'); + expect(result.valid).toBe(false); + expect(result.username).toBeUndefined(); + expect(cacheManager.get).not.toHaveBeenCalled(); + }); + + it('should handle non-existent session ID', async () => { + // Create a fake token with valid format but non-existent session ID + const fakeToken = + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im9pZGMtc2Vzc2lvbiJ9.eyJzdWIiOiJvaWRjLXNlc3Npb24iLCJpc3MiOiJ1bnJhaWQtYXBpIiwiYXVkIjoibG9jYWxob3N0IiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjk5OTk5OTk5OTksIm5vbmNlIjoicGFkZGluZy1mb3ItbGVuZ3RoIn0.OIDC-SESSION-00000000-0000-0000-0000-000000000000-xxxxxxxx'; + + // Mock cache get to return null (session not found) + vi.mocked(cacheManager.get).mockResolvedValueOnce(null); + + const result = await service.validateSession(fakeToken); + expect(result.valid).toBe(false); + expect(result.username).toBeUndefined(); + }); + + it('should handle expired sessions', async () => { + // Create a session + const token = await service.createSession('test-provider', 'test-user-id'); + + // Mock cache get to return an expired session + vi.mocked(cacheManager.get).mockResolvedValueOnce({ + id: expect.any(String), + providerId: 'test-provider', + providerUserId: 'test-user-id', + createdAt: new Date(Date.now() - 10 * 60 * 1000), + expiresAt: new Date(Date.now() - 5 * 60 * 1000), // Expired 5 minutes ago + }); + + // Validation should fail due to expiration + const result = await service.validateSession(token); + expect(result.valid).toBe(false); + expect(result.username).toBeUndefined(); + expect(cacheManager.del).toHaveBeenCalled(); + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/sso/oidc-session.service.ts b/api/src/unraid-api/graph/resolvers/sso/oidc-session.service.ts new file mode 100644 index 000000000..9f9e87b53 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/sso/oidc-session.service.ts @@ -0,0 +1,101 @@ +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; + +import type { Cache } from 'cache-manager'; + +export interface OidcSession { + id: string; + providerId: string; + providerUserId: string; + createdAt: Date; + expiresAt: Date; +} + +@Injectable() +export class OidcSessionService { + private readonly logger = new Logger(OidcSessionService.name); + private readonly SESSION_TTL_SECONDS = 2 * 60; // 2 minutes for one-time token security + + constructor(@Inject(CACHE_MANAGER) private readonly cacheManager: Cache) {} + + async createSession(providerId: string, providerUserId: string): Promise { + const sessionId = randomUUID(); + const now = new Date(); + + const session: OidcSession = { + id: sessionId, + providerId, + providerUserId, + createdAt: now, + expiresAt: new Date(now.getTime() + this.SESSION_TTL_SECONDS * 1000), + }; + + // Store in cache with TTL + await this.cacheManager.set(sessionId, session, this.SESSION_TTL_SECONDS * 1000); + this.logger.log(`Created OIDC session ${sessionId} for provider ${providerId}`); + + return this.createPaddedToken(sessionId); + } + + async validateSession(token: string): Promise<{ valid: boolean; username?: string }> { + const sessionId = this.extractSessionId(token); + if (!sessionId) { + return { valid: false }; + } + + const session = await this.cacheManager.get(sessionId); + if (!session) { + this.logger.debug(`Session ${sessionId} not found`); + return { valid: false }; + } + + const now = new Date(); + if (now > new Date(session.expiresAt)) { + this.logger.debug(`Session ${sessionId} expired`); + await this.cacheManager.del(sessionId); + return { valid: false }; + } + + // Delete the session immediately after successful validation + // This ensures the token can only be validated once + await this.cacheManager.del(sessionId); + + this.logger.log( + `Validated and invalidated session ${sessionId} for provider ${session.providerId} (one-time use)` + ); + return { valid: true, username: 'root' }; + } + + private createPaddedToken(sessionId: string): string { + // Create a fake JWT structure to exceed 500 characters + // Format: header.payload.signature where signature contains our UUID + const fakeHeader = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im9pZGMtc2Vzc2lvbiJ9'; + const fakePayload = + 'eyJzdWIiOiJvaWRjLXNlc3Npb24iLCJpc3MiOiJ1bnJhaWQtYXBpIiwiYXVkIjoibG9jYWxob3N0IiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjk5OTk5OTk5OTksIm5vbmNlIjoicGFkZGluZy1mb3ItbGVuZ3RoIn0'; + + // Embed the session ID in the signature part with padding + const signaturePart = `OIDC-SESSION-${sessionId}-` + 'x'.repeat(400); + + return `${fakeHeader}.${fakePayload}.${signaturePart}`; + } + + private extractSessionId(token: string): string | null { + try { + const parts = token.split('.'); + if (parts.length !== 3) { + return null; + } + + const signature = parts[2]; + const match = signature.match(/^OIDC-SESSION-([a-f0-9-]+)-/); + if (!match) { + return null; + } + + return match[1]; + } catch { + return null; + } + } +} diff --git a/api/src/unraid-api/graph/resolvers/sso/oidc-state.service.spec.ts b/api/src/unraid-api/graph/resolvers/sso/oidc-state.service.spec.ts new file mode 100644 index 000000000..5052864a9 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/sso/oidc-state.service.spec.ts @@ -0,0 +1,204 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OidcStateService } from '@app/unraid-api/graph/resolvers/sso/oidc-state.service.js'; + +describe('OidcStateService', () => { + let service: OidcStateService; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + // Create a single instance for all tests in a describe block + service = new OidcStateService(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('generateSecureState', () => { + it('should generate a state with provider prefix and signed token', () => { + const providerId = 'test-provider'; + const clientState = 'client-state-123'; + + const state = service.generateSecureState(providerId, clientState); + + expect(state).toBeTruthy(); + expect(typeof state).toBe('string'); + expect(state.startsWith(`${providerId}:`)).toBe(true); + + // Extract signed portion and verify format (nonce.timestamp.signature) + const signed = state.substring(providerId.length + 1); + expect(signed.split('.').length).toBe(3); + }); + + it('should generate unique states for each call', () => { + const providerId = 'test-provider'; + const clientState = 'client-state-123'; + + const state1 = service.generateSecureState(providerId, clientState); + const state2 = service.generateSecureState(providerId, clientState); + + expect(state1).not.toBe(state2); + }); + }); + + describe('validateSecureState', () => { + it('should validate a valid state token', () => { + const providerId = 'test-provider'; + const clientState = 'client-state-123'; + + const state = service.generateSecureState(providerId, clientState); + const result = service.validateSecureState(state, providerId); + + expect(result.isValid).toBe(true); + expect(result.clientState).toBe(clientState); + expect(result.error).toBeUndefined(); + }); + + it('should reject state with wrong provider ID', () => { + const providerId = 'test-provider'; + const clientState = 'client-state-123'; + + const state = service.generateSecureState(providerId, clientState); + const result = service.validateSecureState(state, 'wrong-provider'); + + expect(result.isValid).toBe(false); + expect(result.error).toBe('Provider ID mismatch in state'); + }); + + it('should reject expired state tokens', () => { + const providerId = 'test-provider'; + const clientState = 'client-state-123'; + + const state = service.generateSecureState(providerId, clientState); + + // Fast forward time beyond expiration (11 minutes) + vi.advanceTimersByTime(11 * 60 * 1000); + + const result = service.validateSecureState(state, providerId); + + expect(result.isValid).toBe(false); + expect(result.error).toBe('State token has expired'); + }); + + it('should reject reused state tokens', () => { + const providerId = 'test-provider'; + const clientState = 'client-state-123'; + + const state = service.generateSecureState(providerId, clientState); + + // First validation should succeed + const result1 = service.validateSecureState(state, providerId); + expect(result1.isValid).toBe(true); + + // Second validation should fail (replay attack prevention) + const result2 = service.validateSecureState(state, providerId); + expect(result2.isValid).toBe(false); + expect(result2.error).toBe('State token not found or already used'); + }); + + it('should reject invalid state tokens', () => { + const result = service.validateSecureState('invalid.state.token', 'test-provider'); + + expect(result.isValid).toBe(false); + expect(result.error).toBe('Invalid state format'); + }); + + it('should reject tampered state tokens', () => { + const providerId = 'test-provider'; + const clientState = 'client-state-123'; + + const state = service.generateSecureState(providerId, clientState); + + // Tamper with the signature + const parts = state.split('.'); + parts[2] = parts[2].slice(0, -4) + 'XXXX'; + const tamperedState = parts.join('.'); + + const result = service.validateSecureState(tamperedState, providerId); + + expect(result.isValid).toBe(false); + expect(result.error).toBe('Invalid state signature'); + }); + }); + + describe('extractProviderFromState', () => { + it('should extract provider from state prefix', () => { + const state = 'provider-id:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature'; + const result = service.extractProviderFromState(state); + + expect(result).toBe('provider-id'); + }); + + it('should handle states with multiple colons', () => { + const state = 'provider-id:jwt:with:colons'; + const result = service.extractProviderFromState(state); + + expect(result).toBe('provider-id'); + }); + + it('should return null for invalid format', () => { + const result = service.extractProviderFromState('invalid-state'); + + expect(result).toBeNull(); + }); + }); + + describe('extractProviderFromLegacyState', () => { + it('should extract provider from legacy colon-separated format', () => { + const result = service.extractProviderFromLegacyState('provider-id:client-state'); + + expect(result.providerId).toBe('provider-id'); + expect(result.originalState).toBe('client-state'); + }); + + it('should handle multiple colons in legacy format', () => { + const result = service.extractProviderFromLegacyState( + 'provider-id:client:state:with:colons' + ); + + expect(result.providerId).toBe('provider-id'); + expect(result.originalState).toBe('client:state:with:colons'); + }); + + it('should return empty provider for JWT format', () => { + const jwtState = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature'; + const result = service.extractProviderFromLegacyState(jwtState); + + expect(result.providerId).toBe(''); + expect(result.originalState).toBe(jwtState); + }); + + it('should return empty provider for unknown format', () => { + const result = service.extractProviderFromLegacyState('some-random-state'); + + expect(result.providerId).toBe(''); + expect(result.originalState).toBe('some-random-state'); + }); + }); + + describe('cleanupExpiredStates', () => { + it('should clean up expired states periodically', () => { + const providerId = 'test-provider'; + + // Generate multiple states + service.generateSecureState(providerId, 'state1'); + service.generateSecureState(providerId, 'state2'); + service.generateSecureState(providerId, 'state3'); + + // Fast forward past expiration + vi.advanceTimersByTime(11 * 60 * 1000); + + // Generate a new state that shouldn't be cleaned + const validState = service.generateSecureState(providerId, 'state4'); + + // Trigger cleanup (happens every minute) + vi.advanceTimersByTime(60 * 1000); + + // The new state should still be valid + const result = service.validateSecureState(validState, providerId); + expect(result.isValid).toBe(true); + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/sso/oidc-state.service.ts b/api/src/unraid-api/graph/resolvers/sso/oidc-state.service.ts new file mode 100644 index 000000000..50a17a15b --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/sso/oidc-state.service.ts @@ -0,0 +1,201 @@ +import { Injectable, Logger } from '@nestjs/common'; +import crypto from 'crypto'; + +interface StateData { + nonce: string; + clientState: string; + timestamp: number; + providerId: string; +} + +@Injectable() +export class OidcStateService { + private readonly logger = new Logger(OidcStateService.name); + private readonly stateCache = new Map(); + private readonly hmacSecret: string; + private readonly STATE_TTL_SECONDS = 600; // 10 minutes + + constructor() { + // Always generate a new secret on API restart for security + // This ensures state tokens cannot be reused across restarts + this.hmacSecret = crypto.randomBytes(32).toString('hex'); + this.logger.debug('Generated new OIDC state secret for this session'); + + // Clean up expired states periodically + setInterval(() => this.cleanupExpiredStates(), 60000); // Every minute + } + + generateSecureState(providerId: string, clientState: string): string { + const nonce = crypto.randomBytes(16).toString('hex'); + const timestamp = Date.now(); + + // Store state data in cache + const stateData: StateData = { + nonce, + clientState, + timestamp, + providerId, + }; + this.stateCache.set(nonce, stateData); + + // Create signed state: nonce.timestamp.signature + const dataToSign = `${nonce}.${timestamp}`; + const signature = crypto.createHmac('sha256', this.hmacSecret).update(dataToSign).digest('hex'); + + const signedState = `${dataToSign}.${signature}`; + + this.logger.debug(`Generated secure state for provider ${providerId} with nonce ${nonce}`); + // Return state with provider ID prefix (unencrypted) for routing + return `${providerId}:${signedState}`; + } + + validateSecureState( + state: string, + expectedProviderId: string + ): { isValid: boolean; clientState?: string; error?: string } { + try { + // Extract provider ID and signed state + const parts = state.split(':'); + if (parts.length < 2) { + return { + isValid: false, + error: 'Invalid state format', + }; + } + + const providerId = parts[0]; + const signedState = parts.slice(1).join(':'); + + // Validate provider ID matches + if (providerId !== expectedProviderId) { + this.logger.warn( + `State validation failed: provider mismatch. Expected ${expectedProviderId}, got ${providerId}` + ); + return { + isValid: false, + error: 'Provider ID mismatch in state', + }; + } + + // Parse and verify signature + const stateParts = signedState.split('.'); + if (stateParts.length !== 3) { + return { + isValid: false, + error: 'Invalid state format', + }; + } + + const [nonce, timestampStr, signature] = stateParts; + const timestamp = parseInt(timestampStr, 10); + + // Verify signature + const dataToSign = `${nonce}.${timestampStr}`; + const expectedSignature = crypto + .createHmac('sha256', this.hmacSecret) + .update(dataToSign) + .digest('hex'); + + if (signature !== expectedSignature) { + this.logger.warn(`State validation failed: invalid signature`); + return { + isValid: false, + error: 'Invalid state signature', + }; + } + + // Check timestamp expiration + const now = Date.now(); + const age = now - timestamp; + if (age > this.STATE_TTL_SECONDS * 1000) { + this.logger.warn(`State validation failed: token expired (age: ${age}ms)`); + return { + isValid: false, + error: 'State token has expired', + }; + } + + // Check if state exists in cache (prevents replay attacks) + const cachedState = this.stateCache.get(nonce); + if (!cachedState) { + this.logger.warn( + `State validation failed: nonce ${nonce} not found in cache (possible replay attack)` + ); + return { + isValid: false, + error: 'State token not found or already used', + }; + } + + // Verify the cached provider ID matches + if (cachedState.providerId !== expectedProviderId) { + this.logger.warn(`State validation failed: cached provider mismatch`); + return { + isValid: false, + error: 'Invalid state token', + }; + } + + // Remove from cache to prevent reuse + this.stateCache.delete(nonce); + + this.logger.debug(`State validation successful for provider ${expectedProviderId}`); + return { + isValid: true, + clientState: cachedState.clientState, + }; + } catch (error) { + this.logger.error( + `State validation error: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + return { + isValid: false, + error: 'Invalid state token', + }; + } + } + + extractProviderFromLegacyState(state: string): { providerId: string; originalState: string } { + // Backward compatibility: handle old format states + const parts = state.split(':'); + if (parts.length >= 2 && !state.includes('.')) { + // Old format: providerId:clientState + return { + providerId: parts[0], + originalState: parts.slice(1).join(':'), + }; + } + + // New format (JWT) or unknown format + return { + providerId: '', + originalState: state, + }; + } + + extractProviderFromState(state: string): string | null { + // Extract provider ID from state prefix (no decryption needed) + const parts = state.split(':'); + if (parts.length >= 2) { + return parts[0]; + } + return null; + } + + private cleanupExpiredStates(): void { + const now = Date.now(); + let cleaned = 0; + + for (const [nonce, stateData] of this.stateCache.entries()) { + const age = now - stateData.timestamp; + if (age > this.STATE_TTL_SECONDS * 1000) { + this.stateCache.delete(nonce); + cleaned++; + } + } + + if (cleaned > 0) { + this.logger.debug(`Cleaned up ${cleaned} expired state entries`); + } + } +} diff --git a/api/src/unraid-api/graph/resolvers/sso/oidc-validation.service.ts b/api/src/unraid-api/graph/resolvers/sso/oidc-validation.service.ts new file mode 100644 index 000000000..bbdc814c6 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/sso/oidc-validation.service.ts @@ -0,0 +1,164 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import * as client from 'openid-client'; + +import { OidcProvider } from '@app/unraid-api/graph/resolvers/sso/oidc-provider.model.js'; + +@Injectable() +export class OidcValidationService { + private readonly logger = new Logger(OidcValidationService.name); + + constructor(private readonly configService: ConfigService) {} + + /** + * Validate OIDC provider configuration by attempting discovery + * Returns validation result with helpful error messages for debugging + */ + async validateProvider( + provider: OidcProvider + ): Promise<{ isValid: boolean; error?: string; details?: unknown }> { + try { + // Validate issuer URL is present + if (!provider.issuer) { + return { + isValid: false, + error: 'No issuer URL provided. Please specify the OIDC provider issuer URL.', + details: { type: 'MISSING_ISSUER' }, + }; + } + + // Validate issuer URL is valid + let serverUrl: URL; + try { + serverUrl = new URL(provider.issuer); + } catch (urlError) { + return { + isValid: false, + error: `Invalid issuer URL format: '${provider.issuer}'. Please provide a valid URL.`, + details: { + type: 'INVALID_URL', + originalError: urlError instanceof Error ? urlError.message : String(urlError), + }, + }; + } + + // Configure client options for HTTP if needed + let clientOptions: any = undefined; + if (serverUrl.protocol === 'http:') { + this.logger.debug( + `HTTP issuer URL detected for provider ${provider.id}: ${provider.issuer}` + ); + clientOptions = { + execute: [client.allowInsecureRequests], + }; + } + + // Attempt OIDC discovery + await this.performDiscovery(provider, clientOptions); + return { isValid: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + // Log the raw error for debugging + this.logger.debug(`Raw discovery error for ${provider.id}: ${errorMessage}`); + + // Provide specific error messages for common issues + let userFriendlyError = errorMessage; + let details: Record = {}; + + if (errorMessage.includes('getaddrinfo ENOTFOUND')) { + userFriendlyError = `Cannot resolve domain name. Please check that '${provider.issuer}' is accessible and spelled correctly.`; + details = { type: 'DNS_ERROR', originalError: errorMessage }; + } else if (errorMessage.includes('ECONNREFUSED')) { + userFriendlyError = `Connection refused. The server at '${provider.issuer}' is not accepting connections.`; + details = { type: 'CONNECTION_ERROR', originalError: errorMessage }; + } else if (errorMessage.includes('ECONNRESET') || errorMessage.includes('ETIMEDOUT')) { + userFriendlyError = `Connection timeout. The server at '${provider.issuer}' is not responding.`; + details = { type: 'TIMEOUT_ERROR', originalError: errorMessage }; + } else if (errorMessage.includes('404') || errorMessage.includes('Not Found')) { + const baseUrl = provider.issuer?.endsWith('/.well-known/openid-configuration') + ? provider.issuer.replace('/.well-known/openid-configuration', '') + : provider.issuer; + userFriendlyError = `OIDC discovery endpoint not found. Please verify that '${baseUrl}/.well-known/openid-configuration' exists.`; + details = { type: 'DISCOVERY_NOT_FOUND', originalError: errorMessage }; + } else if (errorMessage.includes('401') || errorMessage.includes('403')) { + userFriendlyError = `Access denied to discovery endpoint. Please check the issuer URL and any authentication requirements.`; + details = { type: 'AUTHENTICATION_ERROR', originalError: errorMessage }; + } else if (errorMessage.includes('unexpected HTTP response status code')) { + // Extract status code if possible + const statusMatch = errorMessage.match(/status code (\d+)/); + const statusCode = statusMatch ? statusMatch[1] : 'unknown'; + const baseUrl = provider.issuer?.endsWith('/.well-known/openid-configuration') + ? provider.issuer.replace('/.well-known/openid-configuration', '') + : provider.issuer; + userFriendlyError = `HTTP ${statusCode} error from discovery endpoint. Please check that '${baseUrl}/.well-known/openid-configuration' returns a valid OIDC discovery document.`; + details = { type: 'HTTP_STATUS_ERROR', statusCode, originalError: errorMessage }; + } else if ( + errorMessage.includes('certificate') || + errorMessage.includes('SSL') || + errorMessage.includes('TLS') + ) { + userFriendlyError = `SSL/TLS certificate error. The server certificate may be invalid or expired.`; + details = { type: 'SSL_ERROR', originalError: errorMessage }; + } else if (errorMessage.includes('JSON') || errorMessage.includes('parse')) { + userFriendlyError = `Invalid OIDC discovery response. The server returned malformed JSON.`; + details = { type: 'INVALID_JSON', originalError: errorMessage }; + } else if (error && (error as any).code === 'OAUTH_RESPONSE_IS_NOT_CONFORM') { + const baseUrl = provider.issuer?.endsWith('/.well-known/openid-configuration') + ? provider.issuer.replace('/.well-known/openid-configuration', '') + : provider.issuer; + userFriendlyError = `Invalid OIDC discovery document. The server at '${baseUrl}/.well-known/openid-configuration' returned a response that doesn't conform to the OpenID Connect Discovery specification. Please verify the endpoint returns valid OIDC metadata.`; + details = { type: 'INVALID_OIDC_DOCUMENT', originalError: errorMessage }; + } + + this.logger.warn(`OIDC validation failed for provider ${provider.id}: ${errorMessage}`); + + // Add debug logging for HTTP status errors + if (errorMessage.includes('unexpected HTTP response status code')) { + const baseUrl = provider.issuer?.endsWith('/.well-known/openid-configuration') + ? provider.issuer.replace('/.well-known/openid-configuration', '') + : provider.issuer; + this.logger.debug(`Attempted to fetch: ${baseUrl}/.well-known/openid-configuration`); + this.logger.debug(`Full error details: ${errorMessage}`); + } + + return { + isValid: false, + error: userFriendlyError, + details, + }; + } + } + + async performDiscovery(provider: OidcProvider, clientOptions?: any): Promise { + if (!provider.issuer) { + throw new Error('No issuer URL provided'); + } + + // Configure client auth method + const clientAuth = provider.clientSecret + ? client.ClientSecretPost(provider.clientSecret) + : undefined; + + const serverUrl = new URL(provider.issuer); + + // Use provided client options or create default options with HTTP support if needed + if (!clientOptions && serverUrl.protocol === 'http:') { + this.logger.debug(`Allowing HTTP for ${provider.id} as specified by user`); + // For openid-client v6, use allowInsecureRequests in the execute array + // This is deprecated but needed for local development with HTTP endpoints + clientOptions = { + execute: [client.allowInsecureRequests], + }; + } + + return client.discovery( + serverUrl, + provider.clientId, + undefined, // client metadata + clientAuth, + clientOptions + ); + } +} diff --git a/api/src/unraid-api/graph/resolvers/sso/public-oidc-provider.model.ts b/api/src/unraid-api/graph/resolvers/sso/public-oidc-provider.model.ts new file mode 100644 index 000000000..e34639518 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/sso/public-oidc-provider.model.ts @@ -0,0 +1,22 @@ +import { Field, ID, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class PublicOidcProvider { + @Field(() => ID) + id!: string; + + @Field(() => String, { nullable: false }) + name!: string; + + @Field(() => String, { nullable: true }) + buttonText?: string; + + @Field(() => String, { nullable: true }) + buttonIcon?: string; + + @Field(() => String, { nullable: true }) + buttonVariant?: string; + + @Field(() => String, { nullable: true }) + buttonStyle?: string; +} diff --git a/api/src/unraid-api/graph/resolvers/sso/sso-settings.types.ts b/api/src/unraid-api/graph/resolvers/sso/sso-settings.types.ts new file mode 100644 index 000000000..fafa5bada --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/sso/sso-settings.types.ts @@ -0,0 +1,7 @@ +import type { OidcConfig } from '@app/unraid-api/graph/resolvers/sso/oidc-config.service.js'; + +declare module '@unraid/shared/services/user-settings.js' { + interface UserSettings { + sso: OidcConfig; + } +} diff --git a/api/src/unraid-api/graph/resolvers/sso/sso.module.ts b/api/src/unraid-api/graph/resolvers/sso/sso.module.ts new file mode 100644 index 000000000..45bb92d70 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/sso/sso.module.ts @@ -0,0 +1,33 @@ +import { CacheModule } from '@nestjs/cache-manager'; +import { Module } from '@nestjs/common'; + +import { UserSettingsModule } from '@unraid/shared/services/user-settings.js'; + +import { OidcAuthService } from '@app/unraid-api/graph/resolvers/sso/oidc-auth.service.js'; +import { OidcConfigPersistence } from '@app/unraid-api/graph/resolvers/sso/oidc-config.service.js'; +import { OidcSessionService } from '@app/unraid-api/graph/resolvers/sso/oidc-session.service.js'; +import { OidcStateService } from '@app/unraid-api/graph/resolvers/sso/oidc-state.service.js'; +import { OidcValidationService } from '@app/unraid-api/graph/resolvers/sso/oidc-validation.service.js'; +import { SsoResolver } from '@app/unraid-api/graph/resolvers/sso/sso.resolver.js'; + +import '@app/unraid-api/graph/resolvers/sso/sso-settings.types.js'; + +@Module({ + imports: [UserSettingsModule, CacheModule.register()], + providers: [ + SsoResolver, + OidcConfigPersistence, + OidcSessionService, + OidcStateService, + OidcAuthService, + OidcValidationService, + ], + exports: [ + OidcConfigPersistence, + OidcSessionService, + OidcStateService, + OidcAuthService, + OidcValidationService, + ], +}) +export class SsoModule {} diff --git a/api/src/unraid-api/graph/resolvers/sso/sso.resolver.ts b/api/src/unraid-api/graph/resolvers/sso/sso.resolver.ts new file mode 100644 index 000000000..4d4da9dd7 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/sso/sso.resolver.ts @@ -0,0 +1,107 @@ +import { Logger } from '@nestjs/common'; +import { Args, Query, Resolver } from '@nestjs/graphql'; + +import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; +import { + AuthActionVerb, + AuthPossession, + UsePermissions, +} from '@unraid/shared/use-permissions.directive.js'; + +import { Public } from '@app/unraid-api/auth/public.decorator.js'; +import { OidcConfigPersistence } from '@app/unraid-api/graph/resolvers/sso/oidc-config.service.js'; +import { OidcProvider } from '@app/unraid-api/graph/resolvers/sso/oidc-provider.model.js'; +import { OidcSessionValidation } from '@app/unraid-api/graph/resolvers/sso/oidc-session-validation.model.js'; +import { OidcSessionService } from '@app/unraid-api/graph/resolvers/sso/oidc-session.service.js'; +import { PublicOidcProvider } from '@app/unraid-api/graph/resolvers/sso/public-oidc-provider.model.js'; + +@Resolver() +export class SsoResolver { + private readonly logger = new Logger(SsoResolver.name); + + constructor( + private readonly oidcConfig: OidcConfigPersistence, + private readonly oidcSessionService: OidcSessionService + ) {} + + @Query(() => [PublicOidcProvider], { + description: 'Get public OIDC provider information for login buttons', + }) + @Public() + public async publicOidcProviders(): Promise { + const providers = await this.oidcConfig.getProviders(); + + // Filter out providers without valid authorization rules + const providersWithRules = providers.filter((provider) => { + // Check if provider has authorization rules + if (!provider.authorizationRules || provider.authorizationRules.length === 0) { + this.logger.debug( + `Hiding provider ${provider.id} from login page - no authorization rules configured` + ); + return false; + } + + // Check if at least one rule is complete and valid + const hasValidRules = provider.authorizationRules.some( + (rule) => + rule.claim && // Has a claim specified + rule.operator && // Has an operator specified + rule.value && // Has values array + rule.value.length > 0 && // Has at least one value + rule.value.some((v) => v && v.trim() !== '') // At least one non-empty value + ); + + if (!hasValidRules) { + this.logger.debug( + `Hiding provider ${provider.id} from login page - no valid rule values` + ); + return false; + } + + return true; + }); + + return providersWithRules.map((provider) => ({ + id: provider.id, + name: provider.name, + buttonText: provider.buttonText, + buttonIcon: provider.buttonIcon, + buttonVariant: provider.buttonVariant, + buttonStyle: provider.buttonStyle, + })); + } + + @Query(() => [OidcProvider], { description: 'Get all configured OIDC providers (admin only)' }) + @UsePermissions({ + action: AuthActionVerb.READ, + resource: 'sso', + possession: AuthPossession.ANY, + }) + public async oidcProviders(): Promise { + return this.oidcConfig.getProviders(); + } + + @Query(() => OidcProvider, { nullable: true, description: 'Get a specific OIDC provider by ID' }) + @UsePermissions({ + action: AuthActionVerb.READ, + resource: 'sso', + possession: AuthPossession.ANY, + }) + public async oidcProvider( + @Args('id', { type: () => PrefixedID }) id: string + ): Promise { + return this.oidcConfig.getProvider(id); + } + + @Query(() => OidcSessionValidation, { + description: 'Validate an OIDC session token (internal use for CLI validation)', + }) + @UsePermissions({ + action: AuthActionVerb.READ, + resource: 'sso', + possession: AuthPossession.ANY, + }) + public async validateOidcSession(@Args('token') token: string): Promise { + return await this.oidcSessionService.validateSession(token); + } +} diff --git a/api/src/unraid-api/graph/sandbox-plugin.ts b/api/src/unraid-api/graph/sandbox-plugin.ts index 799d0b8d6..f1bdcb00e 100644 --- a/api/src/unraid-api/graph/sandbox-plugin.ts +++ b/api/src/unraid-api/graph/sandbox-plugin.ts @@ -28,34 +28,23 @@ const preconditionFailed = (preconditionName: string) => { throw new HttpException(`Precondition failed: ${preconditionName} `, HttpStatus.PRECONDITION_FAILED); }; -export const getPluginBasedOnSandbox = async (sandbox: boolean, csrfToken: string) => { - if (sandbox) { - const { ApolloServerPluginLandingPageLocalDefault } = await import( - '@apollo/server/plugin/landingPage/default' - ); - const plugin = ApolloServerPluginLandingPageLocalDefault({ - footer: false, - includeCookies: true, - document: initialDocument, - embed: { - initialState: { - sharedHeaders: { - 'x-csrf-token': csrfToken, - }, +export const getSandboxPlugin = async (csrfToken: string) => { + const { ApolloServerPluginLandingPageLocalDefault } = await import( + '@apollo/server/plugin/landingPage/default' + ); + const plugin = ApolloServerPluginLandingPageLocalDefault({ + footer: false, + includeCookies: true, + document: initialDocument, + embed: { + initialState: { + sharedHeaders: { + 'x-csrf-token': csrfToken, }, }, - }); - return plugin; - } else { - const { ApolloServerPluginLandingPageProductionDefault } = await import( - '@apollo/server/plugin/landingPage/default' - ); - - const plugin = ApolloServerPluginLandingPageProductionDefault({ - footer: false, - }); - return plugin; - } + }, + }); + return plugin; }; /** @@ -72,11 +61,10 @@ export const getPluginBasedOnSandbox = async (sandbox: boolean, csrfToken: strin * - Initial document state * - Shared headers containing CSRF token */ -async function renderSandboxPage(service: GraphQLServerContext, isSandboxEnabled: () => boolean) { +async function renderSandboxPage(service: GraphQLServerContext) { const { getters } = await import('@app/store/index.js'); - const sandbox = isSandboxEnabled(); const csrfToken = getters.emhttp().var.csrfToken; - const plugin = await getPluginBasedOnSandbox(sandbox, csrfToken); + const plugin = await getSandboxPlugin(csrfToken); if (!plugin.serverWillStart) return preconditionFailed('serverWillStart'); const serverListener = await plugin.serverWillStart(service); @@ -88,15 +76,15 @@ async function renderSandboxPage(service: GraphQLServerContext, isSandboxEnabled } /** - * Apollo plugin to render the GraphQL Sandbox page on-demand based on current server state. + * Apollo plugin to render the GraphQL Sandbox page. * - * Usually, the `ApolloServerPluginLandingPageLocalDefault` plugin configures its - * parameters once, during server startup. This plugin defers the configuration - * and rendering to request-time instead of server startup. + * Access to this page is controlled by the sandbox-access-plugin which blocks + * GET requests when sandbox is disabled. This plugin only handles rendering + * the sandbox UI when it's allowed through. */ -export const createSandboxPlugin = (isSandboxEnabled: () => boolean): ApolloServerPlugin => ({ +export const createSandboxPlugin = (): ApolloServerPlugin => ({ serverWillStart: async (service) => ({ - renderLandingPage: () => renderSandboxPage(service, isSandboxEnabled), + renderLandingPage: () => renderSandboxPage(service), }) satisfies GraphQLServerListener, }); diff --git a/api/src/unraid-api/graph/utils/form-utils.ts b/api/src/unraid-api/graph/utils/form-utils.ts index 97a100475..f3f46cc91 100644 --- a/api/src/unraid-api/graph/utils/form-utils.ts +++ b/api/src/unraid-api/graph/utils/form-utils.ts @@ -1,5 +1,48 @@ import type { ControlElement, LabelElement, Layout, Rule } from '@jsonforms/core'; +/** + * Creates a simple VerticalLayout containing a Label followed by a Control element. + * Useful for detail views within array fields where UnraidSettingsLayout doesn't work well. + */ +export function createSimpleLabeledControl({ + scope, + label, + description, + controlOptions, + rule, +}: { + scope: string; + label: string; + description?: string; + controlOptions?: ControlElement['options']; + rule?: Rule; +}): Layout { + const layout: Layout = { + type: 'VerticalLayout', + elements: [ + { + type: 'Label', + text: label, + options: { + description, + }, + } as LabelElement, + { + type: 'Control', + scope: scope, + options: controlOptions, + } as ControlElement, + ], + }; + + // Add rule if provided + if (rule) { + layout.rule = rule; + } + + return layout; +} + /** * Creates a Layout (typically UnraidSettingsLayout) containing a Label and a Control element. */ @@ -44,3 +87,42 @@ export function createLabeledControl({ } return layout; } + +/** + * Creates an AccordionLayout that wraps child elements in an accordion interface. + * Each element becomes an accordion item with configurable titles and descriptions. + */ +export function createAccordionLayout({ + elements, + defaultOpen, + rule, +}: { + elements: Array< + Layout & { + options?: Layout['options'] & { + accordion?: { + title?: string; + description?: string; + }; + }; + } + >; + defaultOpen?: number | number[] | 'all'; + rule?: Rule; +}): Layout { + const layout: Layout = { + type: 'AccordionLayout', + options: { + accordion: { + defaultOpen, + }, + }, + elements, + }; + + if (rule) { + layout.rule = rule; + } + + return layout; +} diff --git a/api/src/unraid-api/main.ts b/api/src/unraid-api/main.ts index 3fb7471eb..4b753abfa 100644 --- a/api/src/unraid-api/main.ts +++ b/api/src/unraid-api/main.ts @@ -1,5 +1,6 @@ import type { NestFastifyApplication } from '@nestjs/platform-fastify'; import { ValidationPipe } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; import { FastifyAdapter } from '@nestjs/platform-fastify/index.js'; @@ -75,6 +76,39 @@ export async function bootstrapNestServer(): Promise { hsts: false, }); + // Add sandbox access control hook + server.addHook('preHandler', async (request, reply) => { + // Only block GET requests to /graphql when sandbox is disabled + if (request.method === 'GET') { + // Extract pathname without query parameters + const urlPath = request.url.split('?')[0]; + + if (urlPath === '/graphql') { + const configService = app.get(ConfigService); + const sandboxValue = configService.get('api.sandbox'); + + // Robustly coerce to boolean - only true when explicitly true + const sandboxEnabled = + sandboxValue === true || + (typeof sandboxValue === 'string' && sandboxValue.toLowerCase() === 'true'); + + if (!sandboxEnabled) { + reply.status(403).send({ + errors: [ + { + message: 'GraphQL sandbox is disabled. Enable it in the API settings.', + extensions: { + code: 'SANDBOX_DISABLED', + }, + }, + ], + }); + return; + } + } + } + }); + // Allows all origins but still checks authentication app.enableCors({ origin: true, // Allows all origins diff --git a/api/src/unraid-api/rest/rest.controller.ts b/api/src/unraid-api/rest/rest.controller.ts index 93fcde924..1711ce698 100644 --- a/api/src/unraid-api/rest/rest.controller.ts +++ b/api/src/unraid-api/rest/rest.controller.ts @@ -1,17 +1,20 @@ -import { All, Controller, Get, Logger, Param, Req, Res } from '@nestjs/common'; +import { Controller, Get, Logger, Param, Query, Req, Res, UnauthorizedException } from '@nestjs/common'; import { Resource } from '@unraid/shared/graphql.model.js'; -import got from 'got'; import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz'; import type { FastifyReply, FastifyRequest } from '@app/unraid-api/types/fastify.js'; import { Public } from '@app/unraid-api/auth/public.decorator.js'; +import { OidcAuthService } from '@app/unraid-api/graph/resolvers/sso/oidc-auth.service.js'; import { RestService } from '@app/unraid-api/rest/rest.service.js'; @Controller() export class RestController { protected logger = new Logger(RestController.name); - constructor(private readonly restService: RestService) {} + constructor( + private readonly restService: RestService, + private readonly oidcAuthService: OidcAuthService + ) {} @Get('/') @Public() @@ -54,4 +57,113 @@ export class RestController { return res.status(500).send(`Error: Failed to get customizations`); } } + + @Get( + process.env.NODE_ENV === 'development' + ? ['/graphql/api/auth/oidc/authorize/:providerId', '/api/auth/oidc/authorize/:providerId'] + : ['/graphql/api/auth/oidc/authorize/:providerId'] + ) + @Public() + async oidcAuthorize( + @Param('providerId') providerId: string, + @Query('state') state: string, + @Req() req: FastifyRequest, + @Res() res: FastifyReply + ) { + try { + if (!state) { + return res.status(400).send('State parameter is required'); + } + + // Get the host from the request headers + const host = req.headers.host || undefined; + + const authUrl = await this.oidcAuthService.getAuthorizationUrl(providerId, state, host); + this.logger.log(`Redirecting to OIDC provider: ${authUrl}`); + + // Manually set redirect headers for better proxy compatibility + res.status(302); + res.header('Location', authUrl); + return res.send(); + } catch (error: unknown) { + this.logger.error(`OIDC authorize error for provider ${providerId}:`, error); + + // Log more details about the error + if (error instanceof Error) { + this.logger.error(`Error message: ${error.message}`); + if (error.stack) { + this.logger.debug(`Stack trace: ${error.stack}`); + } + } + + return res.status(400).send('Invalid provider or configuration'); + } + } + + @Get( + process.env.NODE_ENV === 'development' + ? ['/graphql/api/auth/oidc/callback', '/api/auth/oidc/callback'] + : ['/graphql/api/auth/oidc/callback'] + ) + @Public() + async oidcCallback( + @Query('code') code: string, + @Query('state') state: string, + @Req() req: FastifyRequest, + @Res() res: FastifyReply + ) { + try { + if (!code || !state) { + return res.status(400).send('Missing required parameters'); + } + + // Extract provider ID from state + const { providerId } = this.oidcAuthService.extractProviderFromState(state); + + // Get the full callback URL as received, respecting reverse proxy headers + const protocol = (req.headers['x-forwarded-proto'] as string) || req.protocol || 'http'; + const host = + (req.headers['x-forwarded-host'] as string) || req.headers.host || 'localhost:3000'; + const fullUrl = `${protocol}://${host}${req.url}`; + + this.logger.debug(`Full callback URL from request: ${fullUrl}`); + + const paddedToken = await this.oidcAuthService.handleCallback( + providerId, + code, + state, + host, + fullUrl + ); + + // Redirect to login page with the token in hash to keep it out of server logs + const loginUrl = `/login#token=${encodeURIComponent(paddedToken)}`; + + // Manually set redirect headers for better proxy compatibility + res.header('Cache-Control', 'no-store'); + res.header('Pragma', 'no-cache'); + res.header('Expires', '0'); + res.status(302); + res.header('Location', loginUrl); + return res.send(); + } catch (error: unknown) { + this.logger.error(`OIDC callback error: ${error}`); + + // Use a generic error message to avoid leaking sensitive information + const errorMessage = 'Authentication failed'; + + // Log detailed error for debugging but don't expose to user + if (error instanceof UnauthorizedException) { + this.logger.debug(`UnauthorizedException occurred during OIDC callback`); + } else if (error instanceof Error) { + this.logger.debug(`Error during OIDC callback: ${error.message}`); + } + + const loginUrl = `/login#error=${encodeURIComponent(errorMessage)}`; + + res.status(302); + res.header('Location', loginUrl); + return res.send(); + } + } } diff --git a/api/src/unraid-api/rest/rest.module.ts b/api/src/unraid-api/rest/rest.module.ts index 55df19811..a5e47265a 100644 --- a/api/src/unraid-api/rest/rest.module.ts +++ b/api/src/unraid-api/rest/rest.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'; import { CliServicesModule } from '@app/unraid-api/cli/cli-services.module.js'; import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js'; +import { SsoModule } from '@app/unraid-api/graph/resolvers/sso/sso.module.js'; import { RestController } from '@app/unraid-api/rest/rest.controller.js'; import { RestService } from '@app/unraid-api/rest/rest.service.js'; @Module({ - imports: [RCloneModule, CliServicesModule], + imports: [RCloneModule, CliServicesModule, SsoModule], controllers: [RestController], providers: [RestService], }) diff --git a/packages/unraid-api-plugin-connect/src/__test__/cloud.service.test.ts b/packages/unraid-api-plugin-connect/src/__test__/cloud.service.test.ts index 335dc9b21..b01458f59 100644 --- a/packages/unraid-api-plugin-connect/src/__test__/cloud.service.test.ts +++ b/packages/unraid-api-plugin-connect/src/__test__/cloud.service.test.ts @@ -45,5 +45,5 @@ describe('CloudService.hardCheckCloud (integration)', () => { } throw error; } - }); + }, { timeout: 10000 }); }); diff --git a/packages/unraid-shared/package.json b/packages/unraid-shared/package.json index 8d5420609..5739a77ad 100644 --- a/packages/unraid-shared/package.json +++ b/packages/unraid-shared/package.json @@ -37,6 +37,7 @@ "@types/lodash-es": "4.17.12", "@types/node": "22.17.1", "@types/ws": "^8.5.13", + "class-transformer": "0.5.1", "class-validator": "0.14.2", "graphql": "16.11.0", "graphql-scalars": "1.24.2", diff --git a/packages/unraid-shared/src/services/user-settings.ts b/packages/unraid-shared/src/services/user-settings.ts index 75d3c3cd5..92dc7dda5 100644 --- a/packages/unraid-shared/src/services/user-settings.ts +++ b/packages/unraid-shared/src/services/user-settings.ts @@ -19,7 +19,7 @@ export interface SettingsFragment { getCurrentValues(): Promise; updateValues( values: Partial - ): Promise<{ restartRequired?: boolean; values: Partial }>; + ): Promise<{ restartRequired?: boolean; values: Partial; warnings?: string[] }>; } /** @@ -117,16 +117,17 @@ export class UserSettingsService { async updateValues( name: T, values: Partial - ): Promise<{ restartRequired?: boolean; values: Partial }> { + ): Promise<{ restartRequired?: boolean; values: Partial; warnings?: string[] }> { const fragment = this.getOrThrow(name); return fragment.updateValues(values); } /** Update values from a namespaced object. */ async updateNamespacedValues( - values: Record - ): Promise<{ restartRequired: boolean; values: Record }> { + values: Record + ): Promise<{ restartRequired: boolean; values: Record; warnings?: string[] }> { let restartRequired = false; + let allWarnings: string[] = []; for (const [key, fragmentValues] of Object.entries(values)) { if (!this.settings.has(key as keyof UserSettings)) { @@ -136,14 +137,26 @@ export class UserSettingsService { const result = await this.updateValues( key as keyof UserSettings, - fragmentValues + fragmentValues as Partial ); if (result.restartRequired) { restartRequired = true; } + // Collect any warnings from individual fragments + if (result.warnings) { + allWarnings = allWarnings.concat(result.warnings); + } } - return { restartRequired, values: await this.getAllValues() }; + const response: { restartRequired: boolean; values: Record; warnings?: string[] } = { + restartRequired, + values: await this.getAllValues() + }; + if (allWarnings.length > 0) { + response.warnings = allWarnings; + } + + return response; } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7bcc487a..57a11c286 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -256,6 +256,9 @@ importers: node-window-polyfill: specifier: 1.0.4 version: 1.0.4 + openid-client: + specifier: ^6.6.2 + version: 6.6.2 p-retry: specifier: 6.2.1 version: 6.2.1 @@ -439,7 +442,7 @@ importers: version: 9.33.0(jiti@2.5.1) eslint-plugin-import: specifier: 2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.39.1(eslint@9.33.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.33.0(jiti@2.5.1)) + version: 2.32.0(@typescript-eslint/parser@8.39.0(eslint@9.33.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@4.4.4)(eslint@9.33.0(jiti@2.5.1)) eslint-plugin-no-relative-import-paths: specifier: 1.6.1 version: 1.6.1 @@ -755,6 +758,9 @@ importers: '@types/ws': specifier: ^8.5.13 version: 8.18.1 + class-transformer: + specifier: 0.5.1 + version: 0.5.1 class-validator: specifier: 0.14.2 version: 0.14.2 @@ -940,6 +946,9 @@ importers: '@vue/tsconfig': specifier: 0.7.0 version: 0.7.0(typescript@5.9.2)(vue@3.5.18(typescript@5.9.2)) + ajv: + specifier: 8.17.1 + version: 8.17.1 concurrently: specifier: 9.2.0 version: 9.2.0 @@ -1076,6 +1085,9 @@ importers: '@vueuse/integrations': specifier: 13.6.0 version: 13.6.0(change-case@5.4.4)(focus-trap@7.6.5)(fuse.js@7.1.0)(jwt-decode@4.0.0)(vue@3.5.18(typescript@5.9.2)) + ajv: + specifier: 8.17.1 + version: 8.17.1 class-variance-authority: specifier: 0.7.1 version: 0.7.1 @@ -10665,6 +10677,9 @@ packages: engines: {node: ^14.16.0 || >=16.10.0} hasBin: true + oauth4webapi@3.6.1: + resolution: {integrity: sha512-b39+drVyA4aNUptFOhkkmGWnG/BE7dT29SW/8PVYElqp7j/DBqzm5SS1G+MUD07XlTcBOAG+6Cb/35Cx2kHIuQ==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -10749,6 +10764,9 @@ packages: resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} hasBin: true + openid-client@6.6.2: + resolution: {integrity: sha512-Xya5TNMnnZuTM6DbHdB4q0S3ig2NTAELnii/ASie1xDEr8iiB8zZbO871OWBdrw++sd3hW6bqWjgcmSy1RTWHA==} + optimism@0.18.1: resolution: {integrity: sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==} @@ -24530,6 +24548,8 @@ snapshots: pkg-types: 2.2.0 tinyexec: 1.0.1 + oauth4webapi@3.6.1: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -24627,6 +24647,11 @@ snapshots: opener@1.5.2: {} + openid-client@6.6.2: + dependencies: + jose: 6.0.12 + oauth4webapi: 3.6.1 + optimism@0.18.1: dependencies: '@wry/caches': 1.0.1 diff --git a/unraid-ui/package.json b/unraid-ui/package.json index 83f9d46fb..645b240ec 100644 --- a/unraid-ui/package.json +++ b/unraid-ui/package.json @@ -43,7 +43,8 @@ }, "peerDependencies": { "tailwindcss": "4.1.11", - "vue": "3.5.18" + "vue": "3.5.18", + "ajv": "8.17.1" }, "dependencies": { "@headlessui/vue": "1.7.23", @@ -84,6 +85,7 @@ "@vitest/ui": "3.2.4", "@vue/test-utils": "2.4.6", "@vue/tsconfig": "0.7.0", + "ajv": "8.17.1", "concurrently": "9.2.0", "eslint": "9.33.0", "eslint-config-prettier": "10.1.8", diff --git a/unraid-ui/src/forms/AccordionLayout.vue b/unraid-ui/src/forms/AccordionLayout.vue new file mode 100644 index 000000000..a6354c704 --- /dev/null +++ b/unraid-ui/src/forms/AccordionLayout.vue @@ -0,0 +1,157 @@ + + + diff --git a/unraid-ui/src/forms/HorizontalLayout.vue b/unraid-ui/src/forms/HorizontalLayout.vue index 5a6bd372e..d502fdd9a 100644 --- a/unraid-ui/src/forms/HorizontalLayout.vue +++ b/unraid-ui/src/forms/HorizontalLayout.vue @@ -15,7 +15,7 @@ */ import { useJsonFormsVisibility } from '@/forms/composables/useJsonFormsVisibility'; -import type { HorizontalLayout } from '@jsonforms/core'; +import type { HorizontalLayout, UISchemaElement } from '@jsonforms/core'; import { DispatchRenderer, type RendererProps } from '@jsonforms/vue'; import { computed } from 'vue'; @@ -35,7 +35,7 @@ const elements = computed(() => {