mirror of
https://github.com/unraid/api.git
synced 2026-02-06 07:58:54 -06:00
feat: implement OIDC provider management in GraphQL API (#1563)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -76,6 +76,9 @@ typescript
|
||||
# Github actions
|
||||
RELEASE_NOTES.md
|
||||
|
||||
# Test backups
|
||||
api/dev/configs/api.json.backup
|
||||
|
||||
# Docker Deploy Folder
|
||||
deploy/*
|
||||
!deploy/.gitkeep
|
||||
|
||||
@@ -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"
|
||||
|
||||
5
api/.gitignore
vendored
5
api/.gitignore
vendored
@@ -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
|
||||
|
||||
34
api/dev/configs/README.md
Normal file
34
api/dev/configs/README.md
Normal file
@@ -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`
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
21
api/dev/configs/oidc.json
Normal file
21
api/dev/configs/oidc.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
api/docs/public/images/advanced-rules.png
Normal file
BIN
api/docs/public/images/advanced-rules.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
BIN
api/docs/public/images/button-customization.png
Normal file
BIN
api/docs/public/images/button-customization.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
BIN
api/docs/public/images/configured-provider.png
Normal file
BIN
api/docs/public/images/configured-provider.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
BIN
api/docs/public/images/default-unraid-provider.png
Normal file
BIN
api/docs/public/images/default-unraid-provider.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
BIN
api/docs/public/images/sso-with-options.png
Normal file
BIN
api/docs/public/images/sso-with-options.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
@@ -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
|
||||
|
||||
402
api/docs/public/oidc-provider-setup.md
Normal file
402
api/docs/public/oidc-provider-setup.md
Normal file
@@ -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
|
||||
|
||||

|
||||
_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
|
||||
|
||||
<details>
|
||||
<summary><strong>Advanced Mode</strong></summary>
|
||||
|
||||
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
|
||||
|
||||
</details>
|
||||
|
||||
## Authorization Rules
|
||||
|
||||

|
||||
_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
|
||||
|
||||
<details>
|
||||
<summary><strong>Advanced Mode Examples</strong></summary>
|
||||
|
||||
#### 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)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Configuration Interface Details</strong></summary>
|
||||
|
||||
### 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
|
||||
|
||||
</details>
|
||||
|
||||
### 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
|
||||
|
||||

|
||||
_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.
|
||||
@@ -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!
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 /),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
36
api/src/__test__/setup/api-json-backup.ts
Normal file
36
api/src/__test__/setup/api-json-backup.ts
Normal file
@@ -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();
|
||||
@@ -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,
|
||||
|
||||
@@ -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] ?? {};
|
||||
|
||||
@@ -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<Scalars['String']['output']>;
|
||||
@@ -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<Array<Scalars['String']['input']>>;
|
||||
name: Scalars['String']['input'];
|
||||
parentId?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
|
||||
export type MutationCreateNotificationArgs = {
|
||||
input: NotificationData;
|
||||
};
|
||||
|
||||
|
||||
export type MutationDeleteDockerEntriesArgs = {
|
||||
entryIds: Array<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
|
||||
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<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
|
||||
export type MutationRemovePluginArgs = {
|
||||
input: PluginManagementInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationSetDockerFolderChildrenArgs = {
|
||||
childrenIds: Array<Scalars['String']['input']>;
|
||||
folderId?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
|
||||
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<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type OidcProvider = {
|
||||
__typename?: 'OidcProvider';
|
||||
/** OAuth2 authorization endpoint URL. If omitted, will be auto-discovered from issuer/.well-known/openid-configuration */
|
||||
authorizationEndpoint?: Maybe<Scalars['String']['output']>;
|
||||
/** Mode for evaluating authorization rules - OR (any rule passes) or AND (all rules must pass). Defaults to OR. */
|
||||
authorizationRuleMode?: Maybe<AuthorizationRuleMode>;
|
||||
/** Flexible authorization rules based on claims */
|
||||
authorizationRules?: Maybe<Array<OidcAuthorizationRule>>;
|
||||
/** URL or base64 encoded icon for the login button */
|
||||
buttonIcon?: Maybe<Scalars['String']['output']>;
|
||||
/** Custom CSS styles for the button (e.g., "background: linear-gradient(to right, #4f46e5, #7c3aed); border-radius: 9999px;") */
|
||||
buttonStyle?: Maybe<Scalars['String']['output']>;
|
||||
/** Custom text for the login button */
|
||||
buttonText?: Maybe<Scalars['String']['output']>;
|
||||
/** Button variant style from Reka UI. See https://reka-ui.com/docs/components/button */
|
||||
buttonVariant?: Maybe<Scalars['String']['output']>;
|
||||
/** OAuth2 client ID registered with the provider */
|
||||
clientId: Scalars['String']['output'];
|
||||
/** OAuth2 client secret (if required by provider) */
|
||||
clientSecret?: Maybe<Scalars['String']['output']>;
|
||||
/** 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<Scalars['String']['output']>;
|
||||
/** Display name of the OIDC provider */
|
||||
name: Scalars['String']['output'];
|
||||
/** OAuth2 scopes to request (e.g., openid, profile, email) */
|
||||
scopes: Array<Scalars['String']['output']>;
|
||||
/** OAuth2 token endpoint URL. If omitted, will be auto-discovered from issuer/.well-known/openid-configuration */
|
||||
tokenEndpoint?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type OidcSessionValidation = {
|
||||
__typename?: 'OidcSessionValidation';
|
||||
username?: Maybe<Scalars['String']['output']>;
|
||||
valid: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type OrganizerContainerResource = {
|
||||
__typename?: 'OrganizerContainerResource';
|
||||
id: Scalars['String']['output'];
|
||||
meta?: Maybe<DockerContainer>;
|
||||
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<Scalars['JSON']['output']>;
|
||||
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<Scalars['String']['output']>;
|
||||
@@ -1266,6 +1350,16 @@ export type ProfileModel = Node & {
|
||||
username: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type PublicOidcProvider = {
|
||||
__typename?: 'PublicOidcProvider';
|
||||
buttonIcon?: Maybe<Scalars['String']['output']>;
|
||||
buttonStyle?: Maybe<Scalars['String']['output']>;
|
||||
buttonText?: Maybe<Scalars['String']['output']>;
|
||||
buttonVariant?: Maybe<Scalars['String']['output']>;
|
||||
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<OidcProvider>;
|
||||
/** Get all configured OIDC providers (admin only) */
|
||||
oidcProviders: Array<OidcProvider>;
|
||||
online: Scalars['Boolean']['output'];
|
||||
owner: Owner;
|
||||
parityHistory: Array<ParityCheck>;
|
||||
/** List all installed plugins with their metadata */
|
||||
plugins: Array<Plugin>;
|
||||
/** Get public OIDC provider information for login buttons */
|
||||
publicOidcProviders: Array<PublicOidcProvider>;
|
||||
publicPartnerInfo?: Maybe<PublicPartnerInfo>;
|
||||
publicTheme: Theme;
|
||||
rclone: RCloneBackupSettings;
|
||||
@@ -1321,6 +1421,8 @@ export type Query = {
|
||||
upsConfiguration: UpsConfiguration;
|
||||
upsDeviceById?: Maybe<UpsDevice>;
|
||||
upsDevices: Array<UpsDevice>;
|
||||
/** 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<Scalars['BigInt']['output']>;
|
||||
};
|
||||
|
||||
export type SsoSettings = Node & {
|
||||
__typename?: 'SsoSettings';
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** List of configured OIDC providers */
|
||||
oidcProviders: Array<OidcProvider>;
|
||||
};
|
||||
|
||||
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<Array<Scalars['String']['output']>>;
|
||||
};
|
||||
|
||||
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<AddPluginMutation, AddPluginMutationVariables>;
|
||||
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<RemovePluginMutation, RemovePluginMutationVariables>;
|
||||
@@ -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<GetSsoUsersQuery, GetSsoUsersQueryVariables>;
|
||||
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<SystemReportQuery, SystemReportQueryVariables>;
|
||||
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<ConnectStatusQuery, ConnectStatusQueryVariables>;
|
||||
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<ServicesQuery, ServicesQueryVariables>;
|
||||
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<ServicesQuery, ServicesQueryVariables>;
|
||||
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<ValidateOidcSessionQuery, ValidateOidcSessionQueryVariables>;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
`);
|
||||
@@ -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: '<token>',
|
||||
})
|
||||
export class ValidateTokenCommand extends CommandRunner {
|
||||
JWKSOffline: ReturnType<typeof createLocalJWKSet>;
|
||||
JWKSOnline: ReturnType<typeof createRemoteJWKSet>;
|
||||
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<void> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
271
api/src/unraid-api/graph/introspection-plugin.spec.ts
Normal file
271
api/src/unraid-api/graph/introspection-plugin.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
43
api/src/unraid-api/graph/introspection-plugin.ts
Normal file
43
api/src/unraid-api/graph/introspection-plugin.ts
Normal file
@@ -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<any>,
|
||||
});
|
||||
@@ -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<void> {
|
||||
// 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<string, any> = {}): Promise<any> {
|
||||
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))}`
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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<string, any>;
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<boolean> {
|
||||
@@ -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<string, unknown>
|
||||
): Promise<UpdateSettingsResponse> {
|
||||
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<OidcProvider[]> {
|
||||
return this.oidcConfig.getProviders();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
// 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<ApiConfig>) {
|
||||
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<SettingSlice> {
|
||||
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 <a href="https://account.unraid.net/settings" target="_blank" rel="noopener noreferrer">account.unraid.net/settings</a>. Requires restart if adding first user.`,
|
||||
description: `Provide a list of Unique Unraid Account ID's. Find yours at <a href="https://account.unraid.net/settings" target="_blank" rel="noopener noreferrer">account.unraid.net/settings</a>.`,
|
||||
},
|
||||
},
|
||||
elements: [
|
||||
createLabeledControl({
|
||||
scope: '#/properties/api/properties/ssoSubIds',
|
||||
label: 'Unraid Connect SSO Users:',
|
||||
description: `Provide a list of Unique Unraid Account IDs. Find yours at <a href="https://account.unraid.net/settings" target="_blank" rel="noopener noreferrer">account.unraid.net/settings</a>. Requires restart if adding first user.`,
|
||||
description: `Provide a list of Unique Unraid Account IDs. Find yours at <a href="https://account.unraid.net/settings" target="_blank" rel="noopener noreferrer">account.unraid.net/settings</a>.`,
|
||||
controlOptions: {
|
||||
inputType: 'text',
|
||||
placeholder: 'UUID',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
1542
api/src/unraid-api/graph/resolvers/sso/oidc-auth.service.test.ts
Normal file
1542
api/src/unraid-api/graph/resolvers/sso/oidc-auth.service.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
667
api/src/unraid-api/graph/resolvers/sso/oidc-auth.service.ts
Normal file
667
api/src/unraid-api/graph/resolvers/sso/oidc-auth.service.ts
Normal file
@@ -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<string, client.Configuration>();
|
||||
|
||||
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<string> {
|
||||
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<string, string> = {
|
||||
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<string> {
|
||||
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<client.Configuration> {
|
||||
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.ClientMetadata> = {
|
||||
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.ClientMetadata> = {
|
||||
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<void> {
|
||||
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`;
|
||||
}
|
||||
}
|
||||
1098
api/src/unraid-api/graph/resolvers/sso/oidc-config.service.ts
Normal file
1098
api/src/unraid-api/graph/resolvers/sso/oidc-config.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
168
api/src/unraid-api/graph/resolvers/sso/oidc-provider.model.ts
Normal file
168
api/src/unraid-api/graph/resolvers/sso/oidc-provider.model.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class OidcSessionValidation {
|
||||
@Field(() => Boolean)
|
||||
valid!: boolean;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
username?: string;
|
||||
}
|
||||
@@ -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>(OidcSessionService);
|
||||
cacheManager = module.get<Cache>(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();
|
||||
});
|
||||
});
|
||||
});
|
||||
101
api/src/unraid-api/graph/resolvers/sso/oidc-session.service.ts
Normal file
101
api/src/unraid-api/graph/resolvers/sso/oidc-session.service.ts
Normal file
@@ -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<string> {
|
||||
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<OidcSession>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
201
api/src/unraid-api/graph/resolvers/sso/oidc-state.service.ts
Normal file
201
api/src/unraid-api/graph/resolvers/sso/oidc-state.service.ts
Normal file
@@ -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<string, StateData>();
|
||||
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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> = {};
|
||||
|
||||
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<client.Configuration> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
33
api/src/unraid-api/graph/resolvers/sso/sso.module.ts
Normal file
33
api/src/unraid-api/graph/resolvers/sso/sso.module.ts
Normal file
@@ -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 {}
|
||||
107
api/src/unraid-api/graph/resolvers/sso/sso.resolver.ts
Normal file
107
api/src/unraid-api/graph/resolvers/sso/sso.resolver.ts
Normal file
@@ -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<PublicOidcProvider[]> {
|
||||
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<OidcProvider[]> {
|
||||
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<OidcProvider | null> {
|
||||
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<OidcSessionValidation> {
|
||||
return await this.oidcSessionService.validateSession(token);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<NestFastifyApplication> {
|
||||
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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -45,5 +45,5 @@ describe('CloudService.hardCheckCloud (integration)', () => {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}, { timeout: 10000 });
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface SettingsFragment<T> {
|
||||
getCurrentValues(): Promise<T>;
|
||||
updateValues(
|
||||
values: Partial<T>
|
||||
): Promise<{ restartRequired?: boolean; values: Partial<T> }>;
|
||||
): Promise<{ restartRequired?: boolean; values: Partial<T>; warnings?: string[] }>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,16 +117,17 @@ export class UserSettingsService {
|
||||
async updateValues<T extends keyof UserSettings>(
|
||||
name: T,
|
||||
values: Partial<UserSettings[T]>
|
||||
): Promise<{ restartRequired?: boolean; values: Partial<UserSettings[T]> }> {
|
||||
): Promise<{ restartRequired?: boolean; values: Partial<UserSettings[T]>; warnings?: string[] }> {
|
||||
const fragment = this.getOrThrow(name);
|
||||
return fragment.updateValues(values);
|
||||
}
|
||||
|
||||
/** Update values from a namespaced object. */
|
||||
async updateNamespacedValues(
|
||||
values: Record<string, any>
|
||||
): Promise<{ restartRequired: boolean; values: Record<string, any> }> {
|
||||
values: Record<string, unknown>
|
||||
): Promise<{ restartRequired: boolean; values: Record<string, unknown>; 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<UserSettings[keyof UserSettings]>
|
||||
);
|
||||
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<string, unknown>; warnings?: string[] } = {
|
||||
restartRequired,
|
||||
values: await this.getAllValues()
|
||||
};
|
||||
if (allWarnings.length > 0) {
|
||||
response.warnings = allWarnings;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
27
pnpm-lock.yaml
generated
27
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
157
unraid-ui/src/forms/AccordionLayout.vue
Normal file
157
unraid-ui/src/forms/AccordionLayout.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div v-if="layout.visible !== false" class="space-y-4">
|
||||
<AccordionRoot :type="'multiple'" :defaultValue="defaultOpenItems" class="space-y-2">
|
||||
<AccordionItem
|
||||
v-for="(element, index) in elements"
|
||||
:key="`${layout.path || ''}-${index}`"
|
||||
:value="`item-${index}`"
|
||||
class="border rounded-lg bg-background"
|
||||
>
|
||||
<AccordionTrigger class="px-4 py-3 hover:bg-muted/50 [&[data-state=open]>svg]:rotate-180">
|
||||
<div class="flex flex-col items-start space-y-1 text-left">
|
||||
<span class="font-medium">
|
||||
{{ getAccordionTitle(element, index) }}
|
||||
</span>
|
||||
<span v-if="getAccordionDescription(element, index)" class="text-sm text-muted-foreground">
|
||||
{{ getAccordionDescription(element, index) }}
|
||||
</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent class="px-4 pb-4 pt-0">
|
||||
<div class="space-y-4">
|
||||
<DispatchRenderer
|
||||
:schema="layout.schema"
|
||||
:uischema="element as UISchemaElement"
|
||||
:path="layout.path || ''"
|
||||
:enabled="layout.enabled"
|
||||
:renderers="layout.renderers"
|
||||
:cells="layout.cells"
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</AccordionRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionRoot,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import { jsonFormsAjv } from '@/forms/config';
|
||||
import type { Layout, UISchemaElement } from '@jsonforms/core';
|
||||
import { isVisible } from '@jsonforms/core';
|
||||
import { DispatchRenderer, useJsonFormsLayout } from '@jsonforms/vue';
|
||||
import type { RendererProps } from '@jsonforms/vue';
|
||||
import { computed, inject } from 'vue';
|
||||
|
||||
const props = defineProps<RendererProps<Layout>>();
|
||||
|
||||
// Use the JsonForms layout composable - returns layout with all necessary props
|
||||
const { layout } = useJsonFormsLayout(props);
|
||||
|
||||
// Try to get the root data from JSONForms context
|
||||
const jsonFormsContext = inject('jsonforms') as { core?: { data?: unknown } } | undefined;
|
||||
|
||||
// Get elements to render - filter out invisible elements based on rules
|
||||
const elements = computed(() => {
|
||||
const allElements = props.uischema?.elements || [];
|
||||
|
||||
// Filter elements based on visibility rules
|
||||
return allElements.filter((element: UISchemaElement & Record<string, unknown>) => {
|
||||
if (!element.rule) {
|
||||
// No rule means always visible
|
||||
return true;
|
||||
}
|
||||
|
||||
// Use JSONForms isVisible function to evaluate rule
|
||||
try {
|
||||
// Get the root data from JSONForms context for rule evaluation
|
||||
const rootData = jsonFormsContext?.core?.data || {};
|
||||
const formData = props.data || layout.data || rootData;
|
||||
const formPath = props.path || layout.path || '';
|
||||
|
||||
const visible = isVisible(element as UISchemaElement, formData, formPath, jsonFormsAjv);
|
||||
return visible;
|
||||
} catch (error) {
|
||||
console.warn('[AccordionLayout] Error evaluating visibility:', error, element.rule);
|
||||
return true; // Default to visible on error
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Extract accordion configuration from options
|
||||
const accordionOptions = computed(() => props.uischema?.options?.accordion || {});
|
||||
|
||||
// Determine which items should be open by default
|
||||
const defaultOpenItems = computed(() => {
|
||||
const defaultOpen = accordionOptions.value?.defaultOpen;
|
||||
const allElements = props.uischema?.elements || [];
|
||||
|
||||
// Helper function to map original index to filtered position
|
||||
const mapOriginalToFiltered = (originalIndex: number): number | null => {
|
||||
const originalElement = allElements[originalIndex];
|
||||
if (!originalElement) return null;
|
||||
|
||||
const filteredIndex = elements.value.findIndex((el) => el === originalElement);
|
||||
return filteredIndex >= 0 ? filteredIndex : null;
|
||||
};
|
||||
|
||||
if (Array.isArray(defaultOpen)) {
|
||||
// Map original indices to filtered positions
|
||||
const mappedItems = defaultOpen
|
||||
.map((originalIndex: number) => {
|
||||
const filteredIndex = mapOriginalToFiltered(originalIndex);
|
||||
return filteredIndex !== null ? `item-${filteredIndex}` : null;
|
||||
})
|
||||
.filter((item) => item !== null);
|
||||
return mappedItems.length > 0 ? mappedItems : elements.value.length > 0 ? ['item-0'] : [];
|
||||
}
|
||||
if (typeof defaultOpen === 'number') {
|
||||
// Map single original index to filtered position
|
||||
const filteredIndex = mapOriginalToFiltered(defaultOpen);
|
||||
return filteredIndex !== null
|
||||
? [`item-${filteredIndex}`]
|
||||
: elements.value.length > 0
|
||||
? ['item-0']
|
||||
: [];
|
||||
}
|
||||
if (defaultOpen === 'all') {
|
||||
return elements.value.map((_, index) => `item-${index}`);
|
||||
}
|
||||
// Default to first item open for better UX if there are elements
|
||||
return elements.value.length > 0 ? ['item-0'] : [];
|
||||
});
|
||||
|
||||
// Get title for accordion item from element options
|
||||
const getAccordionTitle = (
|
||||
element: UISchemaElement & Record<string, unknown>,
|
||||
index: number
|
||||
): string => {
|
||||
return (
|
||||
(element as { options?: { accordion?: { title?: string }; title?: string }; text?: string }).options
|
||||
?.accordion?.title ||
|
||||
(element as { options?: { accordion?: { title?: string }; title?: string }; text?: string }).options
|
||||
?.title ||
|
||||
(element as { options?: { accordion?: { title?: string }; title?: string }; text?: string }).text ||
|
||||
`Section ${index + 1}`
|
||||
);
|
||||
};
|
||||
|
||||
// Get description for accordion item from element options
|
||||
const getAccordionDescription = (
|
||||
element: UISchemaElement & Record<string, unknown>,
|
||||
index: number
|
||||
): string => {
|
||||
return (
|
||||
(element as { options?: { accordion?: { description?: string }; description?: string } }).options
|
||||
?.accordion?.description ||
|
||||
(element as { options?: { accordion?: { description?: string }; description?: string } }).options
|
||||
?.description ||
|
||||
''
|
||||
);
|
||||
};
|
||||
</script>
|
||||
@@ -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(() => {
|
||||
<template v-for="(element, _i) in elements" :key="_i">
|
||||
<DispatchRenderer
|
||||
:schema="layout.layout.value.schema"
|
||||
:uischema="element"
|
||||
:uischema="element as UISchemaElement"
|
||||
:path="layout.layout.value.path"
|
||||
:enabled="layout.layout.value.enabled"
|
||||
:renderers="layout.layout.value.renderers"
|
||||
|
||||
251
unraid-ui/src/forms/ObjectArrayField.vue
Normal file
251
unraid-ui/src/forms/ObjectArrayField.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/common/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/common/tabs';
|
||||
import { jsonFormsAjv } from '@/forms/config';
|
||||
// Import the renderers and AJV directly
|
||||
import { jsonFormsRenderers } from '@/forms/renderers';
|
||||
import type { ControlElement, JsonSchema, UISchemaElement } from '@jsonforms/core';
|
||||
import { JsonForms, useJsonFormsControl } from '@jsonforms/vue';
|
||||
import type { RendererProps } from '@jsonforms/vue';
|
||||
import { Plus, X } from 'lucide-vue-next';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps<RendererProps<ControlElement>>();
|
||||
const { control, handleChange } = useJsonFormsControl(props);
|
||||
|
||||
// Use the imported renderers
|
||||
const renderers = jsonFormsRenderers;
|
||||
|
||||
const items = computed({
|
||||
get: () => {
|
||||
const data = control.value.data ?? [];
|
||||
return Array.isArray(data) ? data : [];
|
||||
},
|
||||
set: (newValue: unknown[]) => {
|
||||
handleChange(control.value.path, newValue);
|
||||
},
|
||||
});
|
||||
|
||||
// Track active tab
|
||||
const activeTab = ref<string>('0');
|
||||
|
||||
// Update active tab when items change
|
||||
watch(
|
||||
() => items.value.length,
|
||||
(newLength, oldLength) => {
|
||||
if (newLength > oldLength) {
|
||||
// When adding a new item, switch to the new tab
|
||||
activeTab.value = String(newLength - 1);
|
||||
} else if (newLength < oldLength && Number(activeTab.value) >= newLength) {
|
||||
// When removing an item, ensure active tab is valid
|
||||
activeTab.value = String(Math.max(0, newLength - 1));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get the detail layout from options or create a default one
|
||||
const detailLayout = computed(() => {
|
||||
const options = control.value.uischema?.options;
|
||||
if (options?.detail) {
|
||||
return options.detail as UISchemaElement;
|
||||
}
|
||||
|
||||
// Create a default vertical layout with all properties
|
||||
const schema = control.value.schema;
|
||||
if (schema?.items && typeof schema.items === 'object' && !Array.isArray(schema.items)) {
|
||||
const properties = schema.items.properties;
|
||||
if (properties && typeof properties === 'object') {
|
||||
return {
|
||||
type: 'VerticalLayout',
|
||||
elements: Object.keys(properties).map((key) => ({
|
||||
type: 'Control',
|
||||
scope: `#/properties/${key}`,
|
||||
})),
|
||||
} as UISchemaElement;
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'VerticalLayout', elements: [] } as UISchemaElement;
|
||||
});
|
||||
|
||||
// Get the property to use as the item label
|
||||
const elementLabelProp = computed(() => {
|
||||
const options = control.value.uischema?.options as Record<string, unknown> | undefined;
|
||||
return (options?.elementLabelProp as string) ?? 'name';
|
||||
});
|
||||
|
||||
// Get the item type name (e.g., "Provider", "Rule", etc.)
|
||||
const itemTypeName = computed(() => {
|
||||
const options = control.value.uischema?.options as Record<string, unknown> | undefined;
|
||||
return (options?.itemTypeName as string) ?? 'Provider';
|
||||
});
|
||||
|
||||
const getItemLabel = (item: unknown, index: number) => {
|
||||
if (item && typeof item === 'object' && item !== null && elementLabelProp.value in item) {
|
||||
const itemObj = item as Record<string, unknown>;
|
||||
return String(itemObj[elementLabelProp.value] || `${itemTypeName.value} ${index + 1}`);
|
||||
}
|
||||
return `${itemTypeName.value} ${index + 1}`;
|
||||
};
|
||||
|
||||
// Check if an item is protected based on options configuration
|
||||
const isItemProtected = (item: unknown): boolean => {
|
||||
const options = control.value.uischema?.options as Record<string, unknown> | undefined;
|
||||
const protectedItems = options?.protectedItems as Array<{ field: string; value: unknown }> | undefined;
|
||||
|
||||
if (!protectedItems || !item || typeof item !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const itemObj = item as Record<string, unknown>;
|
||||
return protectedItems.some((rule) => rule.field in itemObj && itemObj[rule.field] === rule.value);
|
||||
};
|
||||
|
||||
// Get warning message for an item if it matches warning conditions
|
||||
const getItemWarning = (item: unknown): { title: string; description: string } | null => {
|
||||
const options = control.value.uischema?.options as Record<string, unknown> | undefined;
|
||||
const itemWarnings = options?.itemWarnings as
|
||||
| Array<{
|
||||
condition: { field: string; value: unknown };
|
||||
title: string;
|
||||
description: string;
|
||||
}>
|
||||
| undefined;
|
||||
|
||||
if (!itemWarnings || !item || typeof item !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const itemObj = item as Record<string, unknown>;
|
||||
const warning = itemWarnings.find(
|
||||
(w) => w.condition.field in itemObj && itemObj[w.condition.field] === w.condition.value
|
||||
);
|
||||
|
||||
return warning ? { title: warning.title, description: warning.description } : null;
|
||||
};
|
||||
|
||||
const addItem = () => {
|
||||
const schema = control.value.schema;
|
||||
const newItem: Record<string, unknown> = {};
|
||||
|
||||
// Initialize with default values if available
|
||||
if (schema?.items && typeof schema.items === 'object' && !Array.isArray(schema.items)) {
|
||||
const properties = schema.items.properties;
|
||||
if (properties && typeof properties === 'object') {
|
||||
Object.entries(properties).forEach(([key, propSchema]) => {
|
||||
const schema = propSchema as JsonSchema;
|
||||
if (schema.default !== undefined) {
|
||||
newItem[key] = schema.default;
|
||||
} else if (schema.type === 'array') {
|
||||
newItem[key] = [];
|
||||
} else if (schema.type === 'string') {
|
||||
newItem[key] = '';
|
||||
} else if (schema.type === 'number' || schema.type === 'integer') {
|
||||
newItem[key] = 0;
|
||||
} else if (schema.type === 'boolean') {
|
||||
newItem[key] = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
items.value = [...items.value, newItem];
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
const newItems = [...items.value];
|
||||
newItems.splice(index, 1);
|
||||
items.value = newItems;
|
||||
};
|
||||
|
||||
const updateItem = (index: number, newValue: unknown) => {
|
||||
const newItems = [...items.value];
|
||||
newItems[index] = newValue;
|
||||
items.value = newItems;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<Tabs v-if="items.length > 0" v-model="activeTab" class="w-full">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<TabsList class="flex-1">
|
||||
<TabsTrigger
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
:value="String(index)"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
{{ getItemLabel(item, index) }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-9 w-9"
|
||||
:disabled="!control.enabled"
|
||||
@click="addItem"
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TabsContent
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
:value="String(index)"
|
||||
class="mt-0 w-full"
|
||||
>
|
||||
<div class="border rounded-lg p-6 w-full">
|
||||
<div class="flex justify-end mb-4">
|
||||
<Button
|
||||
v-if="!isItemProtected(item)"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-destructive hover:text-destructive"
|
||||
:disabled="!control.enabled"
|
||||
@click="removeItem(index)"
|
||||
>
|
||||
<X class="h-4 w-4 mr-2" />
|
||||
Remove {{ getItemLabel(item, index) }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="w-full max-w-none">
|
||||
<!-- Show warning if item matches protected condition -->
|
||||
<div
|
||||
v-if="getItemWarning(item)"
|
||||
class="mb-4 p-3 bg-warning/10 border border-warning/20 rounded-lg"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-warning">⚠️</span>
|
||||
<div>
|
||||
<div class="font-medium text-warning">{{ getItemWarning(item)?.title }}</div>
|
||||
<div class="text-sm text-muted-foreground mt-1">
|
||||
{{ getItemWarning(item)?.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<JsonForms
|
||||
:data="item"
|
||||
:schema="control.schema.items as JsonSchema"
|
||||
:uischema="detailLayout"
|
||||
:renderers="renderers"
|
||||
:ajv="jsonFormsAjv"
|
||||
:readonly="!control.enabled"
|
||||
@change="({ data }) => updateItem(index, data)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div v-else class="text-center py-8 border-2 border-dashed rounded-lg">
|
||||
<p class="text-muted-foreground mb-4">No {{ itemTypeName.toLowerCase() }}s configured</p>
|
||||
<Button variant="outline" size="md" :disabled="!control.enabled" @click="addItem">
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Add First {{ itemTypeName }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -18,6 +18,14 @@ import {
|
||||
import { DispatchRenderer, useJsonFormsLayout, type RendererProps } from '@jsonforms/vue';
|
||||
import { computed, inject, ref, type Ref } from 'vue';
|
||||
|
||||
// Extend UISchemaElement to include options property
|
||||
interface UISchemaElementWithOptions extends UISchemaElement {
|
||||
options?: {
|
||||
step?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
// Define props based on RendererProps<Layout>
|
||||
const props = defineProps<RendererProps<Layout>>();
|
||||
|
||||
@@ -73,8 +81,9 @@ const updateStep = (newStep: number) => {
|
||||
|
||||
// --- Filtered Elements for Current Step ---
|
||||
const currentStepElements = computed(() => {
|
||||
const filtered = (props.uischema.elements || []).filter((element: UISchemaElement) => {
|
||||
// Check if the element has an 'options' object and an 'step' property
|
||||
const elements = (props.uischema.elements || []) as UISchemaElementWithOptions[];
|
||||
const filtered = elements.filter((element) => {
|
||||
// Check if the element has an 'options' object and a 'step' property
|
||||
return (
|
||||
typeof element.options === 'object' &&
|
||||
element.options !== null &&
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
import SettingsGrid from '@/components/layout/SettingsGrid.vue';
|
||||
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';
|
||||
|
||||
@@ -37,7 +37,7 @@ const elements = computed(() => {
|
||||
<template v-for="(element, _i) in elements" :key="_i">
|
||||
<DispatchRenderer
|
||||
:schema="layout.layout.value.schema"
|
||||
:uischema="element"
|
||||
:uischema="element as UISchemaElement"
|
||||
:path="layout.layout.value.path"
|
||||
:enabled="layout.layout.value.enabled"
|
||||
:renderers="layout.layout.value.renderers"
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { useJsonFormsVisibility } from '@/forms/composables/useJsonFormsVisibility';
|
||||
import type { VerticalLayout } from '@jsonforms/core';
|
||||
import type { UISchemaElement, VerticalLayout } from '@jsonforms/core';
|
||||
import { DispatchRenderer, type RendererProps } from '@jsonforms/vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
@@ -43,7 +43,7 @@ const elements = computed(() => {
|
||||
<template v-for="(element, _i) in elements" :key="_i">
|
||||
<DispatchRenderer
|
||||
:schema="layout.layout.value.schema"
|
||||
:uischema="element"
|
||||
:uischema="element as UISchemaElement"
|
||||
:path="layout.layout.value.path"
|
||||
:enabled="layout.layout.value.enabled"
|
||||
:renderers="layout.layout.value.renderers"
|
||||
|
||||
42
unraid-ui/src/forms/config.ts
Normal file
42
unraid-ui/src/forms/config.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { createAjv } from '@jsonforms/core';
|
||||
import type Ajv from 'ajv';
|
||||
|
||||
export interface JsonFormsConfig {
|
||||
/**
|
||||
* When true, only properties defined in the schema will be rendered.
|
||||
* Extra properties not in the schema are omitted from the form.
|
||||
*/
|
||||
restrict: boolean;
|
||||
/**
|
||||
* When true, leading and trailing whitespace is removed from string inputs
|
||||
* before validation.
|
||||
*/
|
||||
trim: boolean;
|
||||
ajv?: Ajv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and configures an AJV instance for JSONForms rule evaluation
|
||||
* This ensures all JSONForms instances have proper validation and visibility rule support
|
||||
*/
|
||||
export function createJsonFormsAjv(): Ajv {
|
||||
return createAjv({
|
||||
allErrors: true,
|
||||
strict: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared AJV instance for all JSONForms components
|
||||
* This enables proper rule evaluation for visibility conditions
|
||||
*/
|
||||
export const jsonFormsAjv: Ajv = createJsonFormsAjv();
|
||||
|
||||
/**
|
||||
* Default JSONForms configuration with AJV instance
|
||||
*/
|
||||
export const defaultJsonFormsConfig: JsonFormsConfig = {
|
||||
restrict: false,
|
||||
trim: false,
|
||||
ajv: jsonFormsAjv,
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import AccordionLayout from '@/forms/AccordionLayout.vue';
|
||||
import comboBoxRenderer from '@/forms/ComboBoxField.vue';
|
||||
import ControlWrapper from '@/forms/ControlWrapper.vue';
|
||||
import HorizontalLayout from '@/forms/HorizontalLayout.vue';
|
||||
@@ -5,6 +6,7 @@ import inputFieldRenderer from '@/forms/InputField.vue';
|
||||
import LabelRenderer from '@/forms/LabelRenderer.vue';
|
||||
import MissingRenderer from '@/forms/MissingRenderer.vue';
|
||||
import numberFieldRenderer from '@/forms/NumberField.vue';
|
||||
import ObjectArrayField from '@/forms/ObjectArrayField.vue';
|
||||
import PreconditionsLabel from '@/forms/PreconditionsLabel.vue';
|
||||
import selectRenderer from '@/forms/Select.vue';
|
||||
import SteppedLayout from '@/forms/SteppedLayout.vue';
|
||||
@@ -47,8 +49,34 @@ const isStringArray = (schema: JsonSchema): boolean => {
|
||||
return schema.type === 'array' && items?.type === 'string';
|
||||
};
|
||||
|
||||
const isObjectArray = (schema: JsonSchema): boolean => {
|
||||
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return false;
|
||||
const items = schema.items as JsonSchema;
|
||||
return schema.type === 'array' && items?.type === 'object';
|
||||
};
|
||||
|
||||
const isStringOrAnyOfString = (schema: JsonSchema): boolean => {
|
||||
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return false;
|
||||
// Exclude enum fields - they should use select renderer
|
||||
if (schema.enum) return false;
|
||||
// Handle direct string type (but not enums)
|
||||
if (schema.type === 'string') return true;
|
||||
// Handle anyOf with all string types (for optional URL fields)
|
||||
if (schema.anyOf && Array.isArray(schema.anyOf) && schema.anyOf.length === 2) {
|
||||
// Check if it's the pattern we expect: [{type: 'string', minLength: 1}, {type: 'string', maxLength: 0}]
|
||||
const hasMinLength = schema.anyOf.some((s: JsonSchema) => s.type === 'string' && s.minLength === 1);
|
||||
const hasMaxLength = schema.anyOf.some((s: JsonSchema) => s.type === 'string' && s.maxLength === 0);
|
||||
return hasMinLength && hasMaxLength;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const jsonFormsRenderers: JsonFormsRendererRegistryEntry[] = [
|
||||
// Layouts
|
||||
{
|
||||
renderer: markRaw(AccordionLayout),
|
||||
tester: rankWith(4, and(isLayout, uiTypeIs('AccordionLayout'))),
|
||||
},
|
||||
{
|
||||
renderer: markRaw(VerticalLayout),
|
||||
tester: rankWith(2, and(isLayout, uiTypeIs('VerticalLayout'))),
|
||||
@@ -76,16 +104,20 @@ export const jsonFormsRenderers: JsonFormsRendererRegistryEntry[] = [
|
||||
},
|
||||
{
|
||||
renderer: markRaw(withErrorWrapper(selectRenderer)),
|
||||
tester: rankWith(4, and(isEnumControl)),
|
||||
tester: rankWith(6, isEnumControl),
|
||||
},
|
||||
{
|
||||
renderer: markRaw(withErrorWrapper(comboBoxRenderer)),
|
||||
tester: rankWith(4, and(isControl, optionIs('format', 'combobox'))),
|
||||
tester: rankWith(5, and(isControl, optionIs('format', 'combobox'))),
|
||||
},
|
||||
{
|
||||
renderer: markRaw(withErrorWrapper(numberFieldRenderer)),
|
||||
tester: rankWith(4, or(isNumberControl, isIntegerControl)),
|
||||
},
|
||||
{
|
||||
renderer: markRaw(withErrorWrapper(inputFieldRenderer)),
|
||||
tester: rankWith(4, and(isControl, schemaMatches(isStringOrAnyOfString))),
|
||||
},
|
||||
{
|
||||
renderer: markRaw(withErrorWrapper(inputFieldRenderer)),
|
||||
tester: rankWith(3, isStringControl),
|
||||
@@ -94,6 +126,10 @@ export const jsonFormsRenderers: JsonFormsRendererRegistryEntry[] = [
|
||||
renderer: markRaw(withErrorWrapper(StringArrayField)),
|
||||
tester: rankWith(4, and(isControl, schemaMatches(isStringArray), optionIs('format', 'array'))),
|
||||
},
|
||||
{
|
||||
renderer: markRaw(withErrorWrapper(ObjectArrayField)),
|
||||
tester: rankWith(5, and(isControl, schemaMatches(isObjectArray))),
|
||||
},
|
||||
// Labels
|
||||
{
|
||||
renderer: markRaw(PreconditionsLabel),
|
||||
|
||||
@@ -6,6 +6,7 @@ export * from '@/components/ui';
|
||||
|
||||
// JsonForms
|
||||
export * from '@/forms/renderers';
|
||||
export * from '@/forms/config';
|
||||
|
||||
// Lib
|
||||
export * from '@/lib/utils';
|
||||
|
||||
@@ -4,16 +4,20 @@
|
||||
|
||||
import { useQuery } from '@vue/apollo-composable';
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Mock, MockInstance } from 'vitest';
|
||||
|
||||
import SsoButton from '~/components/SsoButton.ce.vue';
|
||||
import SsoButtons from '~/components/sso/SsoButtons.vue';
|
||||
|
||||
const BrandButtonStub = {
|
||||
template: '<button><slot /></button>',
|
||||
props: ['disabled', 'variant', 'class'],
|
||||
// Mock the child components
|
||||
const SsoProviderButtonStub = {
|
||||
template: '<button @click="handleClick" :disabled="disabled">{{ provider.buttonText || `Sign in with ${provider.name}` }}</button>',
|
||||
props: ['provider', 'disabled', 'onClick'],
|
||||
methods: {
|
||||
handleClick(this: { onClick: (id: string) => void; provider: { id: string } }) {
|
||||
this.onClick(this.provider.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Mock the GraphQL composable
|
||||
@@ -27,12 +31,9 @@ vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t }),
|
||||
}));
|
||||
|
||||
vi.mock('~/helpers/urls', () => ({
|
||||
ACCOUNT: 'http://mock-account-url.net',
|
||||
}));
|
||||
|
||||
vi.mock('~/store/account.fragment', () => ({
|
||||
SSO_ENABLED: 'SSO_ENABLED_QUERY',
|
||||
// Mock the GraphQL query
|
||||
vi.mock('~/components/queries/public-oidc-providers.query.js', () => ({
|
||||
PUBLIC_OIDC_PROVIDERS: 'PUBLIC_OIDC_PROVIDERS_QUERY',
|
||||
}));
|
||||
|
||||
// Mock window APIs
|
||||
@@ -52,11 +53,18 @@ const mockCrypto = {
|
||||
}),
|
||||
};
|
||||
vi.stubGlobal('crypto', mockCrypto);
|
||||
let mockLocationHref = 'http://mock-origin.com/login';
|
||||
const mockLocation = {
|
||||
search: '',
|
||||
hash: '',
|
||||
origin: 'http://mock-origin.com',
|
||||
pathname: '/login',
|
||||
href: '',
|
||||
get href() {
|
||||
return mockLocationHref;
|
||||
},
|
||||
set href(value: string) {
|
||||
mockLocationHref = value;
|
||||
},
|
||||
};
|
||||
vi.stubGlobal('location', mockLocation);
|
||||
vi.stubGlobal('URLSearchParams', URLSearchParams);
|
||||
@@ -74,23 +82,28 @@ const mockForm = {
|
||||
const mockPasswordField = { value: '' };
|
||||
const mockUsernameField = { value: '' };
|
||||
|
||||
describe('SsoButton.ce.vue', () => {
|
||||
describe('SsoButtons', () => {
|
||||
let querySelectorSpy: MockInstance;
|
||||
let mockUseQuery: Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllTimers();
|
||||
vi.useFakeTimers();
|
||||
|
||||
mockUseQuery = useQuery as Mock;
|
||||
|
||||
(sessionStorage.getItem as Mock).mockReturnValue(null);
|
||||
(sessionStorage.setItem as Mock).mockClear();
|
||||
(sessionStorage.removeItem as Mock).mockClear();
|
||||
mockForm.requestSubmit.mockClear();
|
||||
mockPasswordField.value = '';
|
||||
mockUsernameField.value = '';
|
||||
mockForm.style.display = 'block';
|
||||
mockLocation.search = '';
|
||||
mockLocation.href = '';
|
||||
mockLocation.hash = '';
|
||||
mockLocationHref = 'http://mock-origin.com/login';
|
||||
mockLocation.pathname = '/login';
|
||||
(fetch as Mock).mockClear();
|
||||
mockUseQuery.mockClear();
|
||||
|
||||
@@ -111,220 +124,300 @@ describe('SsoButton.ce.vue', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders the button when SSO is enabled via GraphQL', () => {
|
||||
it('renders provider buttons when OIDC providers are available', async () => {
|
||||
const mockProviders = [
|
||||
{
|
||||
id: 'unraid-net',
|
||||
name: 'Unraid.net',
|
||||
buttonText: 'Log In With Unraid.net',
|
||||
buttonIcon: null,
|
||||
buttonVariant: 'secondary',
|
||||
buttonStyle: null
|
||||
}
|
||||
];
|
||||
|
||||
mockUseQuery.mockReturnValue({
|
||||
result: { value: { isSSOEnabled: true } },
|
||||
result: { value: { publicOidcProviders: mockProviders } },
|
||||
refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }),
|
||||
});
|
||||
|
||||
const wrapper = mount(SsoButton, {
|
||||
const wrapper = mount(SsoButtons, {
|
||||
global: {
|
||||
stubs: { BrandButton: BrandButtonStub },
|
||||
stubs: {
|
||||
SsoProviderButton: SsoProviderButtonStub,
|
||||
Button: { template: '<button><slot /></button>' }
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(wrapper.findComponent(BrandButtonStub).exists()).toBe(true);
|
||||
|
||||
// Wait for the API check to complete
|
||||
await flushPromises();
|
||||
vi.runAllTimers();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('or');
|
||||
expect(wrapper.text()).toContain('Log In With Unraid.net');
|
||||
});
|
||||
|
||||
it('does not render the button when SSO is disabled via GraphQL', () => {
|
||||
it('does not render buttons when no OIDC providers are configured', async () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
result: { value: { isSSOEnabled: false } },
|
||||
result: { value: { publicOidcProviders: [] } },
|
||||
refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: [] } }),
|
||||
});
|
||||
|
||||
const wrapper = mount(SsoButton, {
|
||||
const wrapper = mount(SsoButtons, {
|
||||
global: {
|
||||
stubs: { BrandButton: BrandButtonStub },
|
||||
stubs: {
|
||||
SsoProviderButton: SsoProviderButtonStub,
|
||||
Button: { template: '<button><slot /></button>' }
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(wrapper.findComponent(BrandButtonStub).exists()).toBe(false);
|
||||
|
||||
await flushPromises();
|
||||
vi.runAllTimers();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).not.toContain('or');
|
||||
expect(wrapper.findAll('button')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not render the button when GraphQL result is null/undefined', () => {
|
||||
it('shows checking message while API is being polled', async () => {
|
||||
const refetchMock = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('API not available'))
|
||||
.mockResolvedValueOnce({ data: { publicOidcProviders: [] } });
|
||||
|
||||
mockUseQuery.mockReturnValue({
|
||||
result: { value: null },
|
||||
refetch: refetchMock,
|
||||
});
|
||||
|
||||
const wrapper = mount(SsoButton, {
|
||||
const wrapper = mount(SsoButtons, {
|
||||
global: {
|
||||
stubs: { BrandButton: BrandButtonStub },
|
||||
stubs: {
|
||||
SsoProviderButton: SsoProviderButtonStub,
|
||||
Button: { template: '<button><slot /></button>' }
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(wrapper.findComponent(BrandButtonStub).exists()).toBe(false);
|
||||
expect(wrapper.text()).not.toContain('or');
|
||||
|
||||
expect(wrapper.text()).toContain('Checking authentication options...');
|
||||
|
||||
// Advance timers to trigger the polling
|
||||
await flushPromises();
|
||||
vi.advanceTimersByTime(2000);
|
||||
await flushPromises();
|
||||
|
||||
// After successful API response, checking message should disappear
|
||||
expect(wrapper.text()).not.toContain('Checking authentication options...');
|
||||
});
|
||||
|
||||
it('does not render the button when GraphQL result is undefined', () => {
|
||||
it('navigates to the OIDC provider URL on button click', async () => {
|
||||
const mockProviders = [
|
||||
{
|
||||
id: 'unraid-net',
|
||||
name: 'Unraid.net',
|
||||
buttonText: 'Log In With Unraid.net',
|
||||
buttonIcon: null,
|
||||
buttonVariant: 'secondary',
|
||||
buttonStyle: null
|
||||
}
|
||||
];
|
||||
|
||||
mockUseQuery.mockReturnValue({
|
||||
result: { value: undefined },
|
||||
result: { value: { publicOidcProviders: mockProviders } },
|
||||
refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }),
|
||||
});
|
||||
|
||||
const wrapper = mount(SsoButton, {
|
||||
const wrapper = mount(SsoButtons, {
|
||||
global: {
|
||||
stubs: { BrandButton: BrandButtonStub },
|
||||
stubs: {
|
||||
SsoProviderButton: SsoProviderButtonStub,
|
||||
Button: { template: '<button><slot /></button>' }
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(wrapper.findComponent(BrandButtonStub).exists()).toBe(false);
|
||||
expect(wrapper.text()).not.toContain('or');
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
vi.runAllTimers();
|
||||
await flushPromises();
|
||||
|
||||
it('navigates to the external SSO URL on button click', async () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
result: { value: { isSSOEnabled: true } },
|
||||
});
|
||||
|
||||
const wrapper = mount(SsoButton, {
|
||||
global: {
|
||||
stubs: { BrandButton: BrandButtonStub },
|
||||
},
|
||||
});
|
||||
|
||||
const button = wrapper.findComponent(BrandButtonStub);
|
||||
const button = wrapper.find('button');
|
||||
await button.trigger('click');
|
||||
|
||||
expect(sessionStorage.setItem).toHaveBeenCalledTimes(1);
|
||||
// Should set state and provider in sessionStorage
|
||||
expect(sessionStorage.setItem).toHaveBeenCalledWith('sso_state', expect.any(String));
|
||||
expect(sessionStorage.setItem).toHaveBeenCalledWith('sso_provider', 'unraid-net');
|
||||
|
||||
const generatedState = (sessionStorage.setItem as Mock).mock.calls[0][1];
|
||||
const expectedUrl = new URL('sso', 'http://mock-account-url.net');
|
||||
const expectedCallbackUrl = new URL('login', 'http://mock-origin.com');
|
||||
const expectedUrl = `/graphql/api/auth/oidc/authorize/unraid-net?state=${encodeURIComponent(generatedState)}`;
|
||||
|
||||
expectedUrl.searchParams.append('callbackUrl', expectedCallbackUrl.toString());
|
||||
expectedUrl.searchParams.append('state', generatedState);
|
||||
|
||||
expect(mockLocation.href).toBe(expectedUrl.toString());
|
||||
expect(mockLocation.href).toBe(expectedUrl);
|
||||
});
|
||||
|
||||
it('handles SSO callback in onMounted hook successfully', async () => {
|
||||
it('handles OIDC callback with token successfully', async () => {
|
||||
const mockProviders = [
|
||||
{
|
||||
id: 'unraid-net',
|
||||
name: 'Unraid.net',
|
||||
buttonText: 'Log In With Unraid.net'
|
||||
}
|
||||
];
|
||||
|
||||
mockUseQuery.mockReturnValue({
|
||||
result: { value: { isSSOEnabled: true } },
|
||||
result: { value: { publicOidcProviders: mockProviders } },
|
||||
refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }),
|
||||
});
|
||||
|
||||
const mockCode = 'mock_auth_code';
|
||||
const mockState = 'mock_session_state_value';
|
||||
const mockAccessToken = 'mock_access_token_123';
|
||||
|
||||
mockLocation.search = `?code=${mockCode}&state=${mockState}`;
|
||||
(sessionStorage.getItem as Mock).mockReturnValue(mockState);
|
||||
(fetch as Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ access_token: mockAccessToken }),
|
||||
} as Response);
|
||||
const mockToken = 'mock_access_token_123';
|
||||
mockLocation.search = ''; // No query params - using hash instead
|
||||
mockLocation.pathname = '/login';
|
||||
mockLocationHref = `http://mock-origin.com/login#token=${mockToken}`;
|
||||
mockLocation.hash = `#token=${mockToken}`;
|
||||
|
||||
// Mount the component so that onMounted hook is called
|
||||
mount(SsoButton, {
|
||||
mount(SsoButtons, {
|
||||
global: {
|
||||
stubs: { BrandButton: BrandButtonStub },
|
||||
stubs: {
|
||||
SsoProviderButton: SsoProviderButtonStub,
|
||||
Button: { template: '<button><slot /></button>' }
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(sessionStorage.getItem).toHaveBeenCalledWith('sso_state');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledWith(new URL('/api/oauth2/token', 'http://mock-account-url.net'), {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
code: mockCode,
|
||||
client_id: 'CONNECT_SERVER_SSO',
|
||||
grant_type: 'authorization_code',
|
||||
}),
|
||||
});
|
||||
expect(mockForm.style.display).toBe('none');
|
||||
expect(mockUsernameField.value).toBe('root');
|
||||
expect(mockPasswordField.value).toBe(mockAccessToken);
|
||||
expect(mockPasswordField.value).toBe(mockToken);
|
||||
expect(mockForm.requestSubmit).toHaveBeenCalledTimes(1);
|
||||
// Should clear the URL hash after processing
|
||||
expect(mockHistory.replaceState).toHaveBeenCalledWith({}, 'Mock Title', '/login');
|
||||
});
|
||||
|
||||
it('handles SSO callback error in onMounted hook', async () => {
|
||||
it('handles OIDC callback error from backend', async () => {
|
||||
const mockProviders = [
|
||||
{
|
||||
id: 'unraid-net',
|
||||
name: 'Unraid.net',
|
||||
buttonText: 'Log In With Unraid.net'
|
||||
}
|
||||
];
|
||||
|
||||
mockUseQuery.mockReturnValue({
|
||||
result: { value: { isSSOEnabled: true } },
|
||||
result: { value: { publicOidcProviders: mockProviders } },
|
||||
refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }),
|
||||
});
|
||||
|
||||
const mockCode = 'mock_auth_code_error';
|
||||
const mockState = 'mock_session_state_error';
|
||||
|
||||
mockLocation.search = `?code=${mockCode}&state=${mockState}`;
|
||||
(sessionStorage.getItem as Mock).mockReturnValue(mockState);
|
||||
|
||||
const fetchError = new Error('Failed to fetch token');
|
||||
(fetch as Mock).mockRejectedValueOnce(fetchError);
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const wrapper = mount(SsoButton, {
|
||||
const errorMessage = 'Authentication failed';
|
||||
mockLocation.search = ''; // No query params - using hash instead
|
||||
mockLocation.pathname = '/login';
|
||||
mockLocationHref = `http://mock-origin.com/login#error=${encodeURIComponent(errorMessage)}`;
|
||||
mockLocation.hash = `#error=${encodeURIComponent(errorMessage)}`;
|
||||
|
||||
const wrapper = mount(SsoButtons, {
|
||||
global: {
|
||||
stubs: { BrandButton: BrandButtonStub },
|
||||
stubs: {
|
||||
SsoProviderButton: SsoProviderButtonStub,
|
||||
Button: { template: '<button><slot /></button>' }
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(sessionStorage.getItem).toHaveBeenCalledWith('sso_state');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Error fetching token', fetchError);
|
||||
|
||||
const errorElement = wrapper.find('p.text-red-500');
|
||||
|
||||
expect(errorElement.exists()).toBe(true);
|
||||
expect(errorElement.text()).toBe('Error fetching token');
|
||||
|
||||
const button = wrapper.findComponent(BrandButtonStub);
|
||||
|
||||
expect(button.text()).toBe('Try Again');
|
||||
expect(errorElement.text()).toBe(errorMessage);
|
||||
|
||||
expect(mockForm.style.display).toBe('block');
|
||||
expect(mockForm.requestSubmit).not.toHaveBeenCalled();
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
|
||||
// The URL cleanup happens with both hash and query params being removed
|
||||
const expectedUrl = mockLocation.pathname;
|
||||
expect(mockHistory.replaceState).toHaveBeenCalledWith({}, 'Mock Title', expectedUrl);
|
||||
});
|
||||
|
||||
it('handles SSO callback when state does not match', async () => {
|
||||
it('redirects to OIDC callback endpoint when code and state are present', async () => {
|
||||
const mockProviders = [
|
||||
{
|
||||
id: 'unraid-net',
|
||||
name: 'Unraid.net',
|
||||
buttonText: 'Log In With Unraid.net'
|
||||
}
|
||||
];
|
||||
|
||||
mockUseQuery.mockReturnValue({
|
||||
result: { value: { isSSOEnabled: true } },
|
||||
result: { value: { publicOidcProviders: mockProviders } },
|
||||
refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }),
|
||||
});
|
||||
|
||||
const mockCode = 'mock_auth_code';
|
||||
const mockState = 'mock_session_state_value';
|
||||
const differentState = 'different_state_value';
|
||||
|
||||
mockLocation.search = `?code=${mockCode}&state=${mockState}`;
|
||||
(sessionStorage.getItem as Mock).mockReturnValue(differentState);
|
||||
mockLocation.pathname = '/login';
|
||||
|
||||
const wrapper = mount(SsoButton, {
|
||||
mount(SsoButtons, {
|
||||
global: {
|
||||
stubs: { BrandButton: BrandButtonStub },
|
||||
stubs: {
|
||||
SsoProviderButton: SsoProviderButtonStub,
|
||||
Button: { template: '<button><slot /></button>' }
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
// Should not make any fetch calls when state doesn't match
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(mockForm.requestSubmit).not.toHaveBeenCalled();
|
||||
expect(wrapper.findComponent(BrandButtonStub).text()).toBe('Log In With Unraid.net');
|
||||
// Should redirect to the OIDC callback endpoint
|
||||
const expectedUrl = `/graphql/api/auth/oidc/callback?code=${encodeURIComponent(mockCode)}&state=${encodeURIComponent(mockState)}`;
|
||||
expect(mockLocation.href).toBe(expectedUrl);
|
||||
});
|
||||
|
||||
it('handles SSO callback when no code is present', async () => {
|
||||
it('handles multiple OIDC providers', async () => {
|
||||
const mockProviders = [
|
||||
{
|
||||
id: 'unraid-net',
|
||||
name: 'Unraid.net',
|
||||
buttonText: 'Log In With Unraid.net',
|
||||
buttonIcon: null,
|
||||
buttonVariant: 'secondary',
|
||||
buttonStyle: null
|
||||
},
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
buttonText: 'Sign in with Google',
|
||||
buttonIcon: 'https://google.com/icon.png',
|
||||
buttonVariant: 'outline',
|
||||
buttonStyle: 'background: white;'
|
||||
}
|
||||
];
|
||||
|
||||
mockUseQuery.mockReturnValue({
|
||||
result: { value: { isSSOEnabled: true } },
|
||||
result: { value: { publicOidcProviders: mockProviders } },
|
||||
refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }),
|
||||
});
|
||||
|
||||
mockLocation.search = '?state=some_state';
|
||||
(sessionStorage.getItem as Mock).mockReturnValue('some_state');
|
||||
|
||||
const wrapper = mount(SsoButton, {
|
||||
const wrapper = mount(SsoButtons, {
|
||||
global: {
|
||||
stubs: { BrandButton: BrandButtonStub },
|
||||
stubs: {
|
||||
SsoProviderButton: SsoProviderButtonStub,
|
||||
Button: { template: '<button><slot /></button>' }
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
await flushPromises();
|
||||
|
||||
// Should not make any fetch calls when no code is present
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(mockForm.requestSubmit).not.toHaveBeenCalled();
|
||||
expect(wrapper.findComponent(BrandButtonStub).text()).toBe('Log In With Unraid.net');
|
||||
vi.runAllTimers();
|
||||
await flushPromises();
|
||||
|
||||
const buttons = wrapper.findAll('button');
|
||||
expect(buttons).toHaveLength(2);
|
||||
expect(wrapper.text()).toContain('Log In With Unraid.net');
|
||||
expect(wrapper.text()).toContain('Sign in with Google');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { storeToRefs } from 'pinia';
|
||||
import { watchDebounced } from '@vueuse/core';
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
|
||||
import { BrandButton, jsonFormsRenderers, Label, SettingsGrid } from '@unraid/ui';
|
||||
import { BrandButton, jsonFormsRenderers, jsonFormsAjv, Label, SettingsGrid } from '@unraid/ui';
|
||||
import { JsonForms } from '@jsonforms/vue';
|
||||
|
||||
import { useServerStore } from '~/store/server';
|
||||
@@ -34,16 +34,7 @@ watch(result, () => {
|
||||
// unified values are namespaced (e.g., { api: { ... } })
|
||||
formState.value = structuredClone(result.value.settings.unified.values ?? {});
|
||||
});
|
||||
const restartRequired = computed(() => {
|
||||
interface SandboxValues {
|
||||
api?: {
|
||||
sandbox?: boolean;
|
||||
};
|
||||
}
|
||||
const currentSandbox = (settings.value?.values as SandboxValues)?.api?.sandbox;
|
||||
const updatedSandbox = (formState.value as SandboxValues)?.api?.sandbox;
|
||||
return currentSandbox !== updatedSandbox;
|
||||
});
|
||||
// Remove the computed restartRequired since we get it from the mutation response
|
||||
|
||||
/**--------------------------------------------
|
||||
* Update Settings Actions
|
||||
@@ -57,6 +48,7 @@ const {
|
||||
} = useMutation(updateConnectSettings);
|
||||
|
||||
const isUpdating = ref(false);
|
||||
const actualRestartRequired = ref(false);
|
||||
|
||||
// prevent ui flash if loading finishes too fast
|
||||
watchDebounced(
|
||||
@@ -70,9 +62,10 @@ watchDebounced(
|
||||
);
|
||||
|
||||
// show a toast when the update is done
|
||||
onMutateSettingsDone(() => {
|
||||
onMutateSettingsDone((result) => {
|
||||
actualRestartRequired.value = result.data?.updateSettings?.restartRequired ?? false;
|
||||
globalThis.toast.success('Updated API Settings', {
|
||||
description: restartRequired.value ? 'The API is restarting...' : undefined,
|
||||
description: actualRestartRequired.value ? 'The API is restarting...' : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,6 +116,7 @@ const onChange = ({ data }: { data: Record<string, unknown> }) => {
|
||||
:renderers="renderers"
|
||||
:data="formState"
|
||||
:config="jsonFormsConfig"
|
||||
:ajv="jsonFormsAjv"
|
||||
:readonly="isUpdating"
|
||||
@change="onChange"
|
||||
/>
|
||||
@@ -130,7 +124,6 @@ const onChange = ({ data }: { data: Record<string, unknown> }) => {
|
||||
<div class="mt-6 grid grid-cols-settings gap-y-6 items-baseline">
|
||||
<div class="text-sm text-end">
|
||||
<p v-if="isUpdating">Applying Settings...</p>
|
||||
<p v-else-if="restartRequired">The API will restart after settings are applied.</p>
|
||||
</div>
|
||||
<div class="col-start-2 ml-10 space-y-4">
|
||||
<BrandButton
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, provide, ref, watch } from 'vue';
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
|
||||
import { Button, jsonFormsRenderers } from '@unraid/ui';
|
||||
import { Button, jsonFormsRenderers, jsonFormsAjv } from '@unraid/ui';
|
||||
import { JsonForms } from '@jsonforms/vue';
|
||||
|
||||
import { CREATE_REMOTE } from '~/components/RClone/graphql/rclone.mutations';
|
||||
@@ -223,6 +223,7 @@ provide('isSubmitting', isCreating);
|
||||
:renderers="renderers"
|
||||
:data="formState"
|
||||
:config="jsonFormsConfig"
|
||||
:ajv="jsonFormsAjv"
|
||||
:readonly="isCreating"
|
||||
@change="onChange"
|
||||
/>
|
||||
|
||||
@@ -1,176 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useQuery } from '@vue/apollo-composable';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { SSO_ENABLED } from '~/store/account.fragment';
|
||||
|
||||
import { BrandButton } from '@unraid/ui';
|
||||
import { ACCOUNT } from '~/helpers/urls';
|
||||
|
||||
type CurrentState = 'loading' | 'idle' | 'error';
|
||||
|
||||
const { t } = useI18n();
|
||||
const currentState = ref<CurrentState>('idle');
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const { result } = useQuery(SSO_ENABLED);
|
||||
|
||||
const isSsoEnabled = computed<boolean>(
|
||||
() => result.value?.isSSOEnabled ?? false
|
||||
);
|
||||
|
||||
const getInputFields = (): {
|
||||
form: HTMLFormElement;
|
||||
passwordField: HTMLInputElement;
|
||||
usernameField: HTMLInputElement;
|
||||
} => {
|
||||
const form = document.querySelector('form[action="/login"]') as HTMLFormElement;
|
||||
const passwordField = document.querySelector('input[name=password]') as HTMLInputElement;
|
||||
const usernameField = document.querySelector('input[name=username]') as HTMLInputElement;
|
||||
if (!form || !passwordField || !usernameField) {
|
||||
console.warn('Could not find form, username, or password field');
|
||||
}
|
||||
return { form, passwordField, usernameField };
|
||||
};
|
||||
|
||||
const enterCallbackTokenIntoField = (token: string) => {
|
||||
const { form, passwordField, usernameField } = getInputFields();
|
||||
if (!passwordField || !usernameField || !form) {
|
||||
console.warn('Could not find form, username, or password field');
|
||||
} else {
|
||||
usernameField.value = 'root';
|
||||
passwordField.value = token;
|
||||
// Submit the form
|
||||
form.requestSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const getStateToken = (): string | null => {
|
||||
const state = sessionStorage.getItem('sso_state');
|
||||
return state ?? null;
|
||||
};
|
||||
|
||||
const generateStateToken = (): string => {
|
||||
const array = new Uint8Array(32);
|
||||
window.crypto.getRandomValues(array);
|
||||
const state = Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
sessionStorage.setItem('sso_state', state);
|
||||
return state;
|
||||
};
|
||||
|
||||
const disableFormOnSubmit = () => {
|
||||
const { form } = getInputFields();
|
||||
if (form) {
|
||||
form.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
const reEnableFormOnError = () => {
|
||||
const { form } = getInputFields();
|
||||
if (form) {
|
||||
form.style.display = 'block';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
const code = search.get('code') ?? '';
|
||||
const state = search.get('state') ?? '';
|
||||
const ssoError = search.get('sso_error') ?? '';
|
||||
const sessionState = getStateToken();
|
||||
|
||||
// Check for SSO error parameter
|
||||
if (ssoError) {
|
||||
currentState.value = 'error';
|
||||
// Map common SSO errors to user-friendly messages with translation support
|
||||
const errorMap: Record<string, string> = {
|
||||
'invalid_credentials': t('Invalid Unraid.net credentials'),
|
||||
'user_not_authorized': t('This Unraid.net account is not authorized to access this server'),
|
||||
'sso_disabled': t('SSO login is not enabled on this server'),
|
||||
'token_expired': t('Login session expired. Please try again'),
|
||||
'network_error': t('Network error. Please check your connection'),
|
||||
};
|
||||
error.value = errorMap[ssoError] || t('SSO login failed. Please try again');
|
||||
// Clean up the URL
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('sso_error');
|
||||
window.history.replaceState({}, document.title, url.pathname + url.search);
|
||||
return;
|
||||
}
|
||||
|
||||
if (code && state === sessionState) {
|
||||
disableFormOnSubmit();
|
||||
currentState.value = 'loading';
|
||||
const token = await fetch(new URL('/api/oauth2/token', ACCOUNT), {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
client_id: 'CONNECT_SERVER_SSO',
|
||||
grant_type: 'authorization_code',
|
||||
}),
|
||||
});
|
||||
if (token.ok) {
|
||||
const tokenBody = await token.json();
|
||||
if (!tokenBody.access_token) {
|
||||
throw new Error('Token body did not contain access_token');
|
||||
}
|
||||
enterCallbackTokenIntoField(tokenBody.access_token);
|
||||
if (window.location.search) {
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Failed to fetch token');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching token', err);
|
||||
|
||||
currentState.value = 'error';
|
||||
error.value = t('Error fetching token');
|
||||
reEnableFormOnError();
|
||||
}
|
||||
});
|
||||
|
||||
const buttonText = computed<string>(() => {
|
||||
switch (currentState.value) {
|
||||
case 'loading':
|
||||
return t('Logging in...');
|
||||
case 'error':
|
||||
return t('Try Again');
|
||||
default:
|
||||
return t('Log In With Unraid.net');
|
||||
}
|
||||
});
|
||||
|
||||
const navigateToExternalSSOUrl = () => {
|
||||
const url = new URL('sso', ACCOUNT);
|
||||
const callbackUrlLogin = new URL('login', window.location.origin);
|
||||
const state = generateStateToken();
|
||||
|
||||
url.searchParams.append('callbackUrl', callbackUrlLogin.toString());
|
||||
url.searchParams.append('state', state);
|
||||
|
||||
window.location.href = url.toString();
|
||||
};
|
||||
import SsoButtons from './sso/SsoButtons.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<template v-if="isSsoEnabled">
|
||||
<div class="w-full flex flex-col gap-1 my-1">
|
||||
<p v-if="currentState === 'idle' || currentState === 'error'" class="text-center">{{ t('or') }}</p>
|
||||
<p v-if="currentState === 'error'" class="text-red-500 text-center">{{ error }}</p>
|
||||
<BrandButton
|
||||
:disabled="currentState === 'loading'"
|
||||
variant="outline-primary"
|
||||
class="sso-button"
|
||||
@click="navigateToExternalSSOUrl"
|
||||
>{{ buttonText }}</BrandButton
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<SsoButtons />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@@ -194,14 +27,4 @@ const navigateToExternalSSOUrl = () => {
|
||||
/* Spacing - standard Tailwind value */
|
||||
--spacing: 0.25rem; /* 4px */
|
||||
}
|
||||
|
||||
.sso-button {
|
||||
font-size: 0.875rem !important;
|
||||
font-weight: 600 !important;
|
||||
line-height: 1 !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: 2px !important;
|
||||
padding: 0.75rem 1.5rem !important;
|
||||
border-radius: 0.125rem !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
28
web/components/queries/oidc-providers.query.ts
Normal file
28
web/components/queries/oidc-providers.query.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const OIDC_PROVIDERS = gql`
|
||||
query OidcProviders {
|
||||
settings {
|
||||
sso {
|
||||
oidcProviders {
|
||||
id
|
||||
name
|
||||
clientId
|
||||
issuer
|
||||
authorizationEndpoint
|
||||
tokenEndpoint
|
||||
jwksUri
|
||||
scopes
|
||||
authorizationRules {
|
||||
claim
|
||||
operator
|
||||
value
|
||||
}
|
||||
authorizationRuleMode
|
||||
buttonText
|
||||
buttonIcon
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
14
web/components/queries/public-oidc-providers.query.ts
Normal file
14
web/components/queries/public-oidc-providers.query.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { graphql } from '~/composables/gql/gql.js';
|
||||
|
||||
export const PUBLIC_OIDC_PROVIDERS = graphql(/* GraphQL */`
|
||||
query PublicOidcProviders {
|
||||
publicOidcProviders {
|
||||
id
|
||||
name
|
||||
buttonText
|
||||
buttonIcon
|
||||
buttonVariant
|
||||
buttonStyle
|
||||
}
|
||||
}
|
||||
`);
|
||||
45
web/components/sso/SsoButtons.vue
Normal file
45
web/components/sso/SsoButtons.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useSsoProviders } from './useSsoProviders';
|
||||
import { useSsoAuth } from './useSsoAuth';
|
||||
import SsoProviderButton from './SsoProviderButton.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { oidcProviders, hasProviders, checkingApi } = useSsoProviders();
|
||||
const { currentState, error, navigateToProvider } = useSsoAuth();
|
||||
|
||||
const showError = computed(() => currentState.value === 'error');
|
||||
const showOr = computed(() => (currentState.value === 'idle' || showError.value) && hasProviders.value);
|
||||
const isLoading = computed(() => currentState.value === 'loading');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sso-buttons-container">
|
||||
<template v-if="checkingApi">
|
||||
<div class="w-full flex flex-col gap-1 my-1">
|
||||
<p class="text-center text-gray-500">{{ t('Checking authentication options...') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="hasProviders">
|
||||
<div class="w-full flex flex-col gap-2 my-1">
|
||||
<p v-if="showOr" class="text-center">{{ t('or') }}</p>
|
||||
<p v-if="showError" class="text-red-500 text-center">{{ error }}</p>
|
||||
|
||||
<!-- All OIDC Providers -->
|
||||
<SsoProviderButton
|
||||
v-for="provider in oidcProviders"
|
||||
:key="provider.id"
|
||||
:provider="provider"
|
||||
:disabled="isLoading"
|
||||
:on-click="navigateToProvider"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Component-specific styles if needed */
|
||||
</style>
|
||||
126
web/components/sso/SsoProviderButton.vue
Normal file
126
web/components/sso/SsoProviderButton.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@unraid/ui';
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Provider {
|
||||
id: string;
|
||||
name: string;
|
||||
buttonText?: string | null;
|
||||
buttonIcon?: string | null;
|
||||
buttonVariant?: string | null;
|
||||
buttonStyle?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
provider: Provider;
|
||||
disabled?: boolean;
|
||||
onClick: (providerId: string) => void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const handleClick = () => {
|
||||
props.onClick(props.provider.id);
|
||||
};
|
||||
|
||||
// Extract SVG content from data URI for inline rendering
|
||||
const inlineSvgContent = computed(() => {
|
||||
if (!props.provider.buttonIcon?.includes('data:image/svg+xml;base64,')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const base64Data = props.provider.buttonIcon.replace('data:image/svg+xml;base64,', '');
|
||||
const svgContent = atob(base64Data);
|
||||
return svgContent;
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
console.error('Error parsing SVG content:', e.message);
|
||||
} else {
|
||||
console.error('Error parsing SVG content:', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
:disabled="disabled"
|
||||
:variant="(provider.buttonVariant as any) || 'outline'"
|
||||
class="sso-provider-button"
|
||||
:style="provider.buttonStyle || ''"
|
||||
@click="handleClick"
|
||||
>
|
||||
<div
|
||||
v-if="inlineSvgContent"
|
||||
class="w-6 h-6 mr-2 sso-button-icon-svg flex-shrink-0"
|
||||
v-html="inlineSvgContent"
|
||||
/>
|
||||
<img
|
||||
v-else-if="provider.buttonIcon"
|
||||
:src="provider.buttonIcon"
|
||||
class="w-6 h-6 mr-2 sso-button-icon"
|
||||
:alt="provider.name"
|
||||
>
|
||||
{{ provider.buttonText || `Sign in with ${provider.name}` }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sso-button-icon {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
image-rendering: crisp-edges;
|
||||
image-rendering: pixelated;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@supports (image-rendering: -webkit-optimize-contrast) {
|
||||
.sso-button-icon {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (image-rendering: crisp-edges) {
|
||||
.sso-button-icon {
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
}
|
||||
|
||||
/* For SVG specifically, prefer smooth rendering */
|
||||
.sso-button-icon[src*="svg"] {
|
||||
image-rendering: auto;
|
||||
image-rendering: smooth;
|
||||
}
|
||||
|
||||
/* Inline SVG rendering for perfect quality */
|
||||
.sso-button-icon-svg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sso-button-icon-svg svg {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
/* Enhanced antialiasing for crisp rendering */
|
||||
shape-rendering: geometricPrecision;
|
||||
text-rendering: geometricPrecision;
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
image-rendering: optimize-contrast;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Automatic hover effects for buttons with custom background colors */
|
||||
.sso-provider-button[style*="background-color"]:hover:not(:disabled) {
|
||||
filter: brightness(0.9) !important;
|
||||
}
|
||||
|
||||
.sso-provider-button[style*="background-color"]:active:not(:disabled) {
|
||||
filter: brightness(0.8) !important;
|
||||
transform: translateY(1px) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
146
web/components/sso/useSsoAuth.ts
Normal file
146
web/components/sso/useSsoAuth.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
export type AuthState = 'loading' | 'idle' | 'error';
|
||||
|
||||
export function useSsoAuth() {
|
||||
const { t } = useI18n();
|
||||
const currentState = ref<AuthState>('idle');
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const getInputFields = (): {
|
||||
form: HTMLFormElement;
|
||||
passwordField: HTMLInputElement;
|
||||
usernameField: HTMLInputElement;
|
||||
} => {
|
||||
const form = document.querySelector('form[action="/login"]') as HTMLFormElement;
|
||||
const passwordField = document.querySelector('input[name=password]') as HTMLInputElement;
|
||||
const usernameField = document.querySelector('input[name=username]') as HTMLInputElement;
|
||||
if (!form || !passwordField || !usernameField) {
|
||||
console.warn('Could not find form, username, or password field');
|
||||
}
|
||||
return { form, passwordField, usernameField };
|
||||
};
|
||||
|
||||
const enterCallbackTokenIntoField = (token: string) => {
|
||||
const { form, passwordField, usernameField } = getInputFields();
|
||||
if (!form || !passwordField || !usernameField) {
|
||||
console.warn('Could not find form, username, or password field');
|
||||
return;
|
||||
}
|
||||
|
||||
usernameField.value = 'root';
|
||||
passwordField.value = token;
|
||||
// Submit the form
|
||||
form.requestSubmit();
|
||||
};
|
||||
|
||||
const getStateToken = (): string | null => {
|
||||
const state = sessionStorage.getItem('sso_state');
|
||||
return state ?? null;
|
||||
};
|
||||
|
||||
const generateStateToken = (): string => {
|
||||
const array = new Uint8Array(32);
|
||||
window.crypto.getRandomValues(array);
|
||||
const state = Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
sessionStorage.setItem('sso_state', state);
|
||||
return state;
|
||||
};
|
||||
|
||||
const disableFormOnSubmit = () => {
|
||||
const fields = getInputFields();
|
||||
if (fields?.form) {
|
||||
fields.form.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
const reEnableFormOnError = () => {
|
||||
const fields = getInputFields();
|
||||
if (fields?.form) {
|
||||
fields.form.style.display = 'block';
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToProvider = (providerId: string) => {
|
||||
// Generate state token for CSRF protection
|
||||
const state = generateStateToken();
|
||||
|
||||
// Store provider ID separately since state must be alphanumeric only
|
||||
sessionStorage.setItem('sso_state', state);
|
||||
sessionStorage.setItem('sso_provider', providerId);
|
||||
|
||||
// Redirect to OIDC authorization endpoint with just the state token
|
||||
const authUrl = `/graphql/api/auth/oidc/authorize/${encodeURIComponent(providerId)}?state=${encodeURIComponent(state)}`;
|
||||
window.location.href = authUrl;
|
||||
};
|
||||
|
||||
const handleOAuthCallback = async () => {
|
||||
try {
|
||||
// First check hash parameters (for token and error - keeps them out of server logs)
|
||||
const hashParams = new URLSearchParams(window.location.hash.slice(1));
|
||||
const hashToken = hashParams.get('token');
|
||||
const hashError = hashParams.get('error');
|
||||
|
||||
// Then check query parameters (for OAuth code/state from provider redirects)
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
const code = search.get('code') ?? '';
|
||||
const state = search.get('state') ?? '';
|
||||
const sessionState = getStateToken();
|
||||
|
||||
// Check for error in hash (preferred) or query params (fallback)
|
||||
const errorParam = hashError || search.get('error') || '';
|
||||
if (errorParam) {
|
||||
currentState.value = 'error';
|
||||
error.value = errorParam;
|
||||
|
||||
// Clean up the URL (both hash and query params)
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle OAuth callback if we have a token in hash (from OIDC redirect)
|
||||
const token = hashToken || search.get('token'); // Check hash first, query as fallback
|
||||
if (token) {
|
||||
currentState.value = 'loading';
|
||||
disableFormOnSubmit();
|
||||
enterCallbackTokenIntoField(token);
|
||||
|
||||
// Clean up the URL (both hash and query params)
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Unraid.net SSO callback (comes to /login with code and state)
|
||||
if (code && state && window.location.pathname === '/login') {
|
||||
currentState.value = 'loading';
|
||||
|
||||
// Redirect to our OIDC callback endpoint to exchange the code
|
||||
const callbackUrl = `/graphql/api/auth/oidc/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`;
|
||||
window.location.href = callbackUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
// Error if we have mismatched state
|
||||
if (code && state && state !== sessionState) {
|
||||
currentState.value = 'error';
|
||||
error.value = t('Invalid callback parameters');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching token', err);
|
||||
currentState.value = 'error';
|
||||
error.value = t('Error fetching token');
|
||||
reEnableFormOnError();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
handleOAuthCallback();
|
||||
});
|
||||
|
||||
return {
|
||||
currentState,
|
||||
error,
|
||||
navigateToProvider,
|
||||
};
|
||||
}
|
||||
77
web/components/sso/useSsoProviders.ts
Normal file
77
web/components/sso/useSsoProviders.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useQuery } from '@vue/apollo-composable';
|
||||
import { PUBLIC_OIDC_PROVIDERS } from '../queries/public-oidc-providers.query';
|
||||
|
||||
export interface OidcProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
buttonText?: string | null;
|
||||
buttonIcon?: string | null;
|
||||
buttonVariant?: string | null;
|
||||
buttonStyle?: string | null;
|
||||
}
|
||||
|
||||
export function useSsoProviders() {
|
||||
const pollInterval = ref<NodeJS.Timeout | null>(null);
|
||||
const apiAvailable = ref(false);
|
||||
const checkingApi = ref(true);
|
||||
|
||||
// Query for OIDC providers with polling
|
||||
const { result: providersResult, refetch: refetchProviders } = useQuery(
|
||||
PUBLIC_OIDC_PROVIDERS,
|
||||
null,
|
||||
{
|
||||
fetchPolicy: 'network-only',
|
||||
errorPolicy: 'all',
|
||||
}
|
||||
);
|
||||
|
||||
const oidcProviders = computed<OidcProvider[]>(() =>
|
||||
providersResult.value?.publicOidcProviders ?? []
|
||||
);
|
||||
|
||||
// Check if there are any providers configured
|
||||
const hasProviders = computed(() => oidcProviders.value.length > 0);
|
||||
|
||||
// Check if API is available
|
||||
const checkApiAvailability = async () => {
|
||||
try {
|
||||
await refetchProviders();
|
||||
apiAvailable.value = true;
|
||||
checkingApi.value = false;
|
||||
// Stop polling once API is available
|
||||
if (pollInterval.value) {
|
||||
clearInterval(pollInterval.value);
|
||||
pollInterval.value = null;
|
||||
}
|
||||
} catch {
|
||||
apiAvailable.value = false;
|
||||
// Continue polling if API is not available
|
||||
}
|
||||
};
|
||||
|
||||
// Start polling when component mounts
|
||||
onMounted(() => {
|
||||
checkApiAvailability();
|
||||
// Poll every 2 seconds if API is not available
|
||||
pollInterval.value = setInterval(() => {
|
||||
if (!apiAvailable.value) {
|
||||
checkApiAvailability();
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
// Clean up polling on unmount
|
||||
onUnmounted(() => {
|
||||
if (pollInterval.value) {
|
||||
clearInterval(pollInterval.value);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
oidcProviders,
|
||||
hasProviders,
|
||||
checkingApi,
|
||||
apiAvailable,
|
||||
};
|
||||
}
|
||||
@@ -44,6 +44,8 @@ type Documents = {
|
||||
"\n mutation DeleteRCloneRemote($input: DeleteRCloneRemoteInput!) {\n rclone {\n deleteRCloneRemote(input: $input)\n }\n }\n": typeof types.DeleteRCloneRemoteDocument,
|
||||
"\n query GetRCloneConfigForm($formOptions: RCloneConfigFormInput) {\n rclone {\n configForm(formOptions: $formOptions) {\n id\n dataSchema\n uiSchema\n }\n }\n }\n": typeof types.GetRCloneConfigFormDocument,
|
||||
"\n query ListRCloneRemotes {\n rclone {\n remotes {\n name\n type\n parameters\n config\n }\n }\n }\n": typeof types.ListRCloneRemotesDocument,
|
||||
"\n query OidcProviders {\n settings {\n sso {\n oidcProviders {\n id\n name\n clientId\n issuer\n authorizationEndpoint\n tokenEndpoint\n jwksUri\n scopes\n authorizationRules {\n claim\n operator\n value\n }\n authorizationRuleMode\n buttonText\n buttonIcon\n }\n }\n }\n }\n": typeof types.OidcProvidersDocument,
|
||||
"\n query PublicOidcProviders {\n publicOidcProviders {\n id\n name\n buttonText\n buttonIcon\n buttonVariant\n buttonStyle\n }\n }\n": typeof types.PublicOidcProvidersDocument,
|
||||
"\n query serverInfo {\n info {\n os {\n hostname\n }\n }\n vars {\n comment\n }\n }\n": typeof types.ServerInfoDocument,
|
||||
"\n mutation ConnectSignIn($input: ConnectSignInInput!) {\n connectSignIn(input: $input)\n }\n": typeof types.ConnectSignInDocument,
|
||||
"\n mutation SignOut {\n connectSignOut\n }\n": typeof types.SignOutDocument,
|
||||
@@ -84,6 +86,8 @@ const documents: Documents = {
|
||||
"\n mutation DeleteRCloneRemote($input: DeleteRCloneRemoteInput!) {\n rclone {\n deleteRCloneRemote(input: $input)\n }\n }\n": types.DeleteRCloneRemoteDocument,
|
||||
"\n query GetRCloneConfigForm($formOptions: RCloneConfigFormInput) {\n rclone {\n configForm(formOptions: $formOptions) {\n id\n dataSchema\n uiSchema\n }\n }\n }\n": types.GetRCloneConfigFormDocument,
|
||||
"\n query ListRCloneRemotes {\n rclone {\n remotes {\n name\n type\n parameters\n config\n }\n }\n }\n": types.ListRCloneRemotesDocument,
|
||||
"\n query OidcProviders {\n settings {\n sso {\n oidcProviders {\n id\n name\n clientId\n issuer\n authorizationEndpoint\n tokenEndpoint\n jwksUri\n scopes\n authorizationRules {\n claim\n operator\n value\n }\n authorizationRuleMode\n buttonText\n buttonIcon\n }\n }\n }\n }\n": types.OidcProvidersDocument,
|
||||
"\n query PublicOidcProviders {\n publicOidcProviders {\n id\n name\n buttonText\n buttonIcon\n buttonVariant\n buttonStyle\n }\n }\n": types.PublicOidcProvidersDocument,
|
||||
"\n query serverInfo {\n info {\n os {\n hostname\n }\n }\n vars {\n comment\n }\n }\n": types.ServerInfoDocument,
|
||||
"\n mutation ConnectSignIn($input: ConnectSignInInput!) {\n connectSignIn(input: $input)\n }\n": types.ConnectSignInDocument,
|
||||
"\n mutation SignOut {\n connectSignOut\n }\n": types.SignOutDocument,
|
||||
@@ -228,6 +232,14 @@ export function graphql(source: "\n query GetRCloneConfigForm($formOptions: RCl
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query ListRCloneRemotes {\n rclone {\n remotes {\n name\n type\n parameters\n config\n }\n }\n }\n"): (typeof documents)["\n query ListRCloneRemotes {\n rclone {\n remotes {\n name\n type\n parameters\n config\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query OidcProviders {\n settings {\n sso {\n oidcProviders {\n id\n name\n clientId\n issuer\n authorizationEndpoint\n tokenEndpoint\n jwksUri\n scopes\n authorizationRules {\n claim\n operator\n value\n }\n authorizationRuleMode\n buttonText\n buttonIcon\n }\n }\n }\n }\n"): (typeof documents)["\n query OidcProviders {\n settings {\n sso {\n oidcProviders {\n id\n name\n clientId\n issuer\n authorizationEndpoint\n tokenEndpoint\n jwksUri\n scopes\n authorizationRules {\n claim\n operator\n value\n }\n authorizationRuleMode\n buttonText\n buttonIcon\n }\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query PublicOidcProviders {\n publicOidcProviders {\n id\n name\n buttonText\n buttonIcon\n buttonVariant\n buttonStyle\n }\n }\n"): (typeof documents)["\n query PublicOidcProviders {\n publicOidcProviders {\n id\n name\n buttonText\n buttonIcon\n buttonVariant\n buttonStyle\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
||||
@@ -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<Scalars['String']['output']>;
|
||||
@@ -669,6 +683,7 @@ export type Docker = Node & {
|
||||
containers: Array<DockerContainer>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
networks: Array<DockerNetwork>;
|
||||
organizer: ResolvedOrganizerV1;
|
||||
};
|
||||
|
||||
|
||||
@@ -936,23 +951,28 @@ export type Mutation = {
|
||||
archiveNotification: Notification;
|
||||
archiveNotifications: NotificationOverview;
|
||||
array: ArrayMutations;
|
||||
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;
|
||||
@@ -984,16 +1004,33 @@ export type MutationArchiveNotificationsArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationConfigureUpsArgs = {
|
||||
config: UpsConfigInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationConnectSignInArgs = {
|
||||
input: ConnectSignInInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationCreateDockerFolderArgs = {
|
||||
childrenIds?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
name: Scalars['String']['input'];
|
||||
parentId?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
|
||||
export type MutationCreateNotificationArgs = {
|
||||
input: NotificationData;
|
||||
};
|
||||
|
||||
|
||||
export type MutationDeleteDockerEntriesArgs = {
|
||||
entryIds: Array<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
|
||||
export type MutationDeleteNotificationArgs = {
|
||||
id: Scalars['PrefixedID']['input'];
|
||||
type: NotificationType;
|
||||
@@ -1010,11 +1047,23 @@ export type MutationInitiateFlashBackupArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationMoveDockerEntriesToFolderArgs = {
|
||||
destinationFolderId: Scalars['String']['input'];
|
||||
sourceEntryIds: Array<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
|
||||
export type MutationRemovePluginArgs = {
|
||||
input: PluginManagementInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationSetDockerFolderChildrenArgs = {
|
||||
childrenIds: Array<Scalars['String']['input']>;
|
||||
folderId?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
|
||||
export type MutationSetupRemoteAccessArgs = {
|
||||
input: SetupRemoteAccessInput;
|
||||
};
|
||||
@@ -1122,6 +1171,72 @@ 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<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type OidcProvider = {
|
||||
__typename?: 'OidcProvider';
|
||||
/** OAuth2 authorization endpoint URL. If omitted, will be auto-discovered from issuer/.well-known/openid-configuration */
|
||||
authorizationEndpoint?: Maybe<Scalars['String']['output']>;
|
||||
/** Mode for evaluating authorization rules - OR (any rule passes) or AND (all rules must pass). Defaults to OR. */
|
||||
authorizationRuleMode?: Maybe<AuthorizationRuleMode>;
|
||||
/** Flexible authorization rules based on claims */
|
||||
authorizationRules?: Maybe<Array<OidcAuthorizationRule>>;
|
||||
/** URL or base64 encoded icon for the login button */
|
||||
buttonIcon?: Maybe<Scalars['String']['output']>;
|
||||
/** Custom CSS styles for the button (e.g., "background: linear-gradient(to right, #4f46e5, #7c3aed); border-radius: 9999px;") */
|
||||
buttonStyle?: Maybe<Scalars['String']['output']>;
|
||||
/** Custom text for the login button */
|
||||
buttonText?: Maybe<Scalars['String']['output']>;
|
||||
/** Button variant style from Reka UI. See https://reka-ui.com/docs/components/button */
|
||||
buttonVariant?: Maybe<Scalars['String']['output']>;
|
||||
/** OAuth2 client ID registered with the provider */
|
||||
clientId: Scalars['String']['output'];
|
||||
/** OAuth2 client secret (if required by provider) */
|
||||
clientSecret?: Maybe<Scalars['String']['output']>;
|
||||
/** 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<Scalars['String']['output']>;
|
||||
/** Display name of the OIDC provider */
|
||||
name: Scalars['String']['output'];
|
||||
/** OAuth2 scopes to request (e.g., openid, profile, email) */
|
||||
scopes: Array<Scalars['String']['output']>;
|
||||
/** OAuth2 token endpoint URL. If omitted, will be auto-discovered from issuer/.well-known/openid-configuration */
|
||||
tokenEndpoint?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type OidcSessionValidation = {
|
||||
__typename?: 'OidcSessionValidation';
|
||||
username?: Maybe<Scalars['String']['output']>;
|
||||
valid: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type OrganizerContainerResource = {
|
||||
__typename?: 'OrganizerContainerResource';
|
||||
id: Scalars['String']['output'];
|
||||
meta?: Maybe<DockerContainer>;
|
||||
name: Scalars['String']['output'];
|
||||
type: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type OrganizerResource = {
|
||||
__typename?: 'OrganizerResource';
|
||||
id: Scalars['String']['output'];
|
||||
meta?: Maybe<Scalars['JSON']['output']>;
|
||||
name: Scalars['String']['output'];
|
||||
type: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type Os = Node & {
|
||||
__typename?: 'Os';
|
||||
arch?: Maybe<Scalars['String']['output']>;
|
||||
@@ -1235,6 +1350,16 @@ export type ProfileModel = Node & {
|
||||
username: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type PublicOidcProvider = {
|
||||
__typename?: 'PublicOidcProvider';
|
||||
buttonIcon?: Maybe<Scalars['String']['output']>;
|
||||
buttonStyle?: Maybe<Scalars['String']['output']>;
|
||||
buttonText?: Maybe<Scalars['String']['output']>;
|
||||
buttonVariant?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['ID']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type PublicPartnerInfo = {
|
||||
__typename?: 'PublicPartnerInfo';
|
||||
/** Indicates if a partner logo exists */
|
||||
@@ -1272,11 +1397,17 @@ export type Query = {
|
||||
network: Network;
|
||||
/** Get all notifications */
|
||||
notifications: Notifications;
|
||||
/** Get a specific OIDC provider by ID */
|
||||
oidcProvider?: Maybe<OidcProvider>;
|
||||
/** Get all configured OIDC providers (admin only) */
|
||||
oidcProviders: Array<OidcProvider>;
|
||||
online: Scalars['Boolean']['output'];
|
||||
owner: Owner;
|
||||
parityHistory: Array<ParityCheck>;
|
||||
/** List all installed plugins with their metadata */
|
||||
plugins: Array<Plugin>;
|
||||
/** Get public OIDC provider information for login buttons */
|
||||
publicOidcProviders: Array<PublicOidcProvider>;
|
||||
publicPartnerInfo?: Maybe<PublicPartnerInfo>;
|
||||
publicTheme: Theme;
|
||||
rclone: RCloneBackupSettings;
|
||||
@@ -1287,6 +1418,11 @@ export type Query = {
|
||||
services: Array<Service>;
|
||||
settings: Settings;
|
||||
shares: Array<Share>;
|
||||
upsConfiguration: UpsConfiguration;
|
||||
upsDeviceById?: Maybe<UpsDevice>;
|
||||
upsDevices: Array<UpsDevice>;
|
||||
/** Validate an OIDC session token (internal use for CLI validation) */
|
||||
validateOidcSession: OidcSessionValidation;
|
||||
vars: Vars;
|
||||
/** Get information about all VMs on the system */
|
||||
vms: Vms;
|
||||
@@ -1309,6 +1445,21 @@ export type QueryLogFileArgs = {
|
||||
startLine?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
|
||||
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'];
|
||||
@@ -1433,6 +1584,30 @@ export type RemoveRoleFromApiKeyInput = {
|
||||
role: Role;
|
||||
};
|
||||
|
||||
export type ResolvedOrganizerEntry = OrganizerContainerResource | OrganizerResource | ResolvedOrganizerFolder;
|
||||
|
||||
export type ResolvedOrganizerFolder = {
|
||||
__typename?: 'ResolvedOrganizerFolder';
|
||||
children: Array<ResolvedOrganizerEntry>;
|
||||
id: Scalars['String']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
type: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type ResolvedOrganizerV1 = {
|
||||
__typename?: 'ResolvedOrganizerV1';
|
||||
version: Scalars['Float']['output'];
|
||||
views: Array<ResolvedOrganizerView>;
|
||||
};
|
||||
|
||||
export type ResolvedOrganizerView = {
|
||||
__typename?: 'ResolvedOrganizerView';
|
||||
id: Scalars['String']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
prefs?: Maybe<Scalars['JSON']['output']>;
|
||||
root: ResolvedOrganizerEntry;
|
||||
};
|
||||
|
||||
/** Available resources for permissions */
|
||||
export enum Resource {
|
||||
ACTIVATION_CODE = 'ACTIVATION_CODE',
|
||||
@@ -1508,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;
|
||||
};
|
||||
@@ -1556,6 +1733,13 @@ export type Share = Node & {
|
||||
used?: Maybe<Scalars['BigInt']['output']>;
|
||||
};
|
||||
|
||||
export type SsoSettings = Node & {
|
||||
__typename?: 'SsoSettings';
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** List of configured OIDC providers */
|
||||
oidcProviders: Array<OidcProvider>;
|
||||
};
|
||||
|
||||
export type Subscription = {
|
||||
__typename?: 'Subscription';
|
||||
arraySubscription: UnraidArray;
|
||||
@@ -1567,6 +1751,7 @@ export type Subscription = {
|
||||
ownerSubscription: Owner;
|
||||
parityHistorySubscription: ParityCheck;
|
||||
serversSubscription: Server;
|
||||
upsUpdates: UpsDevice;
|
||||
};
|
||||
|
||||
|
||||
@@ -1617,6 +1802,129 @@ export enum ThemeName {
|
||||
WHITE = 'white'
|
||||
}
|
||||
|
||||
export type UpsBattery = {
|
||||
__typename?: 'UPSBattery';
|
||||
/** Battery charge level as a percentage (0-100). Unit: percent (%). Example: 100 means battery is fully charged */
|
||||
chargeLevel: Scalars['Int']['output'];
|
||||
/** Estimated runtime remaining on battery power. Unit: seconds. Example: 3600 means 1 hour of runtime remaining */
|
||||
estimatedRuntime: Scalars['Int']['output'];
|
||||
/** Battery health status. Possible values: 'Good', 'Replace', 'Unknown'. Indicates if the battery needs replacement */
|
||||
health: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
/** UPS cable connection types */
|
||||
export enum UpsCableType {
|
||||
CUSTOM = 'CUSTOM',
|
||||
ETHER = 'ETHER',
|
||||
SIMPLE = 'SIMPLE',
|
||||
SMART = 'SMART',
|
||||
USB = 'USB'
|
||||
}
|
||||
|
||||
export type UpsConfigInput = {
|
||||
/** Battery level percentage to initiate shutdown. Unit: percent (%) - Valid range: 0-100 */
|
||||
batteryLevel?: InputMaybe<Scalars['Int']['input']>;
|
||||
/** Custom cable configuration (only used when upsCable is CUSTOM). Format depends on specific UPS model */
|
||||
customUpsCable?: InputMaybe<Scalars['String']['input']>;
|
||||
/** Device path or network address for UPS connection. Examples: '/dev/ttyUSB0' for USB, '192.168.1.100:3551' for network */
|
||||
device?: InputMaybe<Scalars['String']['input']>;
|
||||
/** Turn off UPS power after system shutdown. Useful for ensuring complete power cycle */
|
||||
killUps?: InputMaybe<UpsKillPower>;
|
||||
/** Runtime left in minutes to initiate shutdown. Unit: minutes */
|
||||
minutes?: InputMaybe<Scalars['Int']['input']>;
|
||||
/** Override UPS capacity for runtime calculations. Unit: watts (W). Leave unset to use UPS-reported capacity */
|
||||
overrideUpsCapacity?: InputMaybe<Scalars['Int']['input']>;
|
||||
/** Enable or disable the UPS monitoring service */
|
||||
service?: InputMaybe<UpsServiceState>;
|
||||
/** Time on battery before shutdown. Unit: seconds. Set to 0 to disable timeout-based shutdown */
|
||||
timeout?: InputMaybe<Scalars['Int']['input']>;
|
||||
/** Type of cable connecting the UPS to the server */
|
||||
upsCable?: InputMaybe<UpsCableType>;
|
||||
/** UPS communication protocol */
|
||||
upsType?: InputMaybe<UpsType>;
|
||||
};
|
||||
|
||||
export type UpsConfiguration = {
|
||||
__typename?: 'UPSConfiguration';
|
||||
/** Battery level threshold for shutdown. Unit: percent (%). Example: 10 means shutdown when battery reaches 10%. System will shutdown when battery drops to this level */
|
||||
batteryLevel?: Maybe<Scalars['Int']['output']>;
|
||||
/** Custom cable configuration string. Only used when upsCable is set to 'custom'. Format depends on specific UPS model */
|
||||
customUpsCable?: Maybe<Scalars['String']['output']>;
|
||||
/** Device path or network address for UPS connection. Examples: '/dev/ttyUSB0' for USB, '192.168.1.100:3551' for network. Depends on upsType setting */
|
||||
device?: Maybe<Scalars['String']['output']>;
|
||||
/** Kill UPS power after shutdown. Values: 'yes' or 'no'. If 'yes', tells UPS to cut power after system shutdown. Useful for ensuring complete power cycle */
|
||||
killUps?: Maybe<Scalars['String']['output']>;
|
||||
/** Runtime threshold for shutdown. Unit: minutes. Example: 5 means shutdown when 5 minutes runtime remaining. System will shutdown when estimated runtime drops below this */
|
||||
minutes?: Maybe<Scalars['Int']['output']>;
|
||||
/** Override UPS model name. Used for display purposes. Leave unset to use UPS-reported model */
|
||||
modelName?: Maybe<Scalars['String']['output']>;
|
||||
/** Network server mode. Values: 'on' or 'off'. Enable to allow network clients to monitor this UPS */
|
||||
netServer?: Maybe<Scalars['String']['output']>;
|
||||
/** Network Information Server (NIS) IP address. Default: '0.0.0.0' (listen on all interfaces). IP address for apcupsd network information server */
|
||||
nisIp?: Maybe<Scalars['String']['output']>;
|
||||
/** Override UPS capacity for runtime calculations. Unit: volt-amperes (VA). Example: 1500 for a 1500VA UPS. Leave unset to use UPS-reported capacity */
|
||||
overrideUpsCapacity?: Maybe<Scalars['Int']['output']>;
|
||||
/** UPS service state. Values: 'enable' or 'disable'. Controls whether the UPS monitoring service is running */
|
||||
service?: Maybe<Scalars['String']['output']>;
|
||||
/** Timeout for UPS communications. Unit: seconds. Example: 0 means no timeout. Time to wait for UPS response before considering it offline */
|
||||
timeout?: Maybe<Scalars['Int']['output']>;
|
||||
/** Type of cable connecting the UPS to the server. Common values: 'usb', 'smart', 'ether', 'custom'. Determines communication protocol */
|
||||
upsCable?: Maybe<Scalars['String']['output']>;
|
||||
/** UPS name for network monitoring. Used to identify this UPS on the network. Example: 'SERVER_UPS' */
|
||||
upsName?: Maybe<Scalars['String']['output']>;
|
||||
/** UPS communication type. Common values: 'usb', 'net', 'snmp', 'dumb', 'pcnet', 'modbus'. Defines how the server communicates with the UPS */
|
||||
upsType?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type UpsDevice = {
|
||||
__typename?: 'UPSDevice';
|
||||
/** Battery-related information */
|
||||
battery: UpsBattery;
|
||||
/** Unique identifier for the UPS device. Usually based on the model name or a generated ID */
|
||||
id: Scalars['ID']['output'];
|
||||
/** UPS model name/number. Example: 'APC Back-UPS Pro 1500' */
|
||||
model: Scalars['String']['output'];
|
||||
/** Display name for the UPS device. Can be customized by the user */
|
||||
name: Scalars['String']['output'];
|
||||
/** Power-related information */
|
||||
power: UpsPower;
|
||||
/** Current operational status of the UPS. Common values: 'Online', 'On Battery', 'Low Battery', 'Replace Battery', 'Overload', 'Offline'. 'Online' means running on mains power, 'On Battery' means running on battery backup */
|
||||
status: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
/** Kill UPS power after shutdown option */
|
||||
export enum UpsKillPower {
|
||||
NO = 'NO',
|
||||
YES = 'YES'
|
||||
}
|
||||
|
||||
export type UpsPower = {
|
||||
__typename?: 'UPSPower';
|
||||
/** Input voltage from the wall outlet/mains power. Unit: volts (V). Example: 120.5 for typical US household voltage */
|
||||
inputVoltage: Scalars['Float']['output'];
|
||||
/** Current load on the UPS as a percentage of its capacity. Unit: percent (%). Example: 25 means UPS is loaded at 25% of its maximum capacity */
|
||||
loadPercentage: Scalars['Int']['output'];
|
||||
/** Output voltage being delivered to connected devices. Unit: volts (V). Example: 120.5 - should match input voltage when on mains power */
|
||||
outputVoltage: Scalars['Float']['output'];
|
||||
};
|
||||
|
||||
/** Service state for UPS daemon */
|
||||
export enum UpsServiceState {
|
||||
DISABLE = 'DISABLE',
|
||||
ENABLE = 'ENABLE'
|
||||
}
|
||||
|
||||
/** UPS communication protocols */
|
||||
export enum UpsType {
|
||||
APCSMART = 'APCSMART',
|
||||
DUMB = 'DUMB',
|
||||
MODBUS = 'MODBUS',
|
||||
NET = 'NET',
|
||||
PCNET = 'PCNET',
|
||||
SNMP = 'SNMP',
|
||||
USB = 'USB'
|
||||
}
|
||||
|
||||
export enum UrlType {
|
||||
DEFAULT = 'DEFAULT',
|
||||
LAN = 'LAN',
|
||||
@@ -1668,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<Array<Scalars['String']['output']>>;
|
||||
};
|
||||
|
||||
export type Uptime = {
|
||||
@@ -2193,6 +2503,16 @@ export type ListRCloneRemotesQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
export type ListRCloneRemotesQuery = { __typename?: 'Query', rclone: { __typename?: 'RCloneBackupSettings', remotes: Array<{ __typename?: 'RCloneRemote', name: string, type: string, parameters: any, config: any }> } };
|
||||
|
||||
export type OidcProvidersQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type OidcProvidersQuery = { __typename?: 'Query', settings: { __typename?: 'Settings', sso: { __typename?: 'SsoSettings', oidcProviders: Array<{ __typename?: 'OidcProvider', id: string, name: string, clientId: string, issuer: string, authorizationEndpoint?: string | null, tokenEndpoint?: string | null, jwksUri?: string | null, scopes: Array<string>, authorizationRuleMode?: AuthorizationRuleMode | null, buttonText?: string | null, buttonIcon?: string | null, authorizationRules?: Array<{ __typename?: 'OidcAuthorizationRule', claim: string, operator: AuthorizationOperator, value: Array<string> }> | null }> } } };
|
||||
|
||||
export type PublicOidcProvidersQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type PublicOidcProvidersQuery = { __typename?: 'Query', publicOidcProviders: Array<{ __typename?: 'PublicOidcProvider', id: string, name: string, buttonText?: string | null, buttonIcon?: string | null, buttonVariant?: string | null, buttonStyle?: string | null }> };
|
||||
|
||||
export type ServerInfoQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
@@ -2266,6 +2586,8 @@ export const CreateRCloneRemoteDocument = {"kind":"Document","definitions":[{"ki
|
||||
export const DeleteRCloneRemoteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteRCloneRemote"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteRCloneRemoteInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"rclone"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteRCloneRemote"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]} as unknown as DocumentNode<DeleteRCloneRemoteMutation, DeleteRCloneRemoteMutationVariables>;
|
||||
export const GetRCloneConfigFormDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetRCloneConfigForm"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"formOptions"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"RCloneConfigFormInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"rclone"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configForm"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"formOptions"},"value":{"kind":"Variable","name":{"kind":"Name","value":"formOptions"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dataSchema"}},{"kind":"Field","name":{"kind":"Name","value":"uiSchema"}}]}}]}}]}}]} as unknown as DocumentNode<GetRCloneConfigFormQuery, GetRCloneConfigFormQueryVariables>;
|
||||
export const ListRCloneRemotesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ListRCloneRemotes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"rclone"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"remotes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"parameters"}},{"kind":"Field","name":{"kind":"Name","value":"config"}}]}}]}}]}}]} as unknown as DocumentNode<ListRCloneRemotesQuery, ListRCloneRemotesQueryVariables>;
|
||||
export const OidcProvidersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"OidcProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"settings"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sso"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"oidcProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"clientId"}},{"kind":"Field","name":{"kind":"Name","value":"issuer"}},{"kind":"Field","name":{"kind":"Name","value":"authorizationEndpoint"}},{"kind":"Field","name":{"kind":"Name","value":"tokenEndpoint"}},{"kind":"Field","name":{"kind":"Name","value":"jwksUri"}},{"kind":"Field","name":{"kind":"Name","value":"scopes"}},{"kind":"Field","name":{"kind":"Name","value":"authorizationRules"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"claim"}},{"kind":"Field","name":{"kind":"Name","value":"operator"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}},{"kind":"Field","name":{"kind":"Name","value":"authorizationRuleMode"}},{"kind":"Field","name":{"kind":"Name","value":"buttonText"}},{"kind":"Field","name":{"kind":"Name","value":"buttonIcon"}}]}}]}}]}}]}}]} as unknown as DocumentNode<OidcProvidersQuery, OidcProvidersQueryVariables>;
|
||||
export const PublicOidcProvidersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"PublicOidcProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"publicOidcProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"buttonText"}},{"kind":"Field","name":{"kind":"Name","value":"buttonIcon"}},{"kind":"Field","name":{"kind":"Name","value":"buttonVariant"}},{"kind":"Field","name":{"kind":"Name","value":"buttonStyle"}}]}}]}}]} as unknown as DocumentNode<PublicOidcProvidersQuery, PublicOidcProvidersQueryVariables>;
|
||||
export const ServerInfoDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"serverInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"info"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"os"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hostname"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"vars"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"comment"}}]}}]}}]} as unknown as DocumentNode<ServerInfoQuery, ServerInfoQueryVariables>;
|
||||
export const ConnectSignInDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ConnectSignIn"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ConnectSignInInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connectSignIn"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode<ConnectSignInMutation, ConnectSignInMutationVariables>;
|
||||
export const SignOutDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SignOut"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connectSignOut"}}]}}]} as unknown as DocumentNode<SignOutMutation, SignOutMutationVariables>;
|
||||
|
||||
@@ -148,9 +148,23 @@ export default defineNuxtConfig({
|
||||
dir: assetsDir,
|
||||
},
|
||||
],
|
||||
devProxy: {
|
||||
'/graphql': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
secure: false,
|
||||
// Important: preserve the host header
|
||||
headers: {
|
||||
'X-Forwarded-Host': 'localhost:3000',
|
||||
'X-Forwarded-Proto': 'http',
|
||||
'X-Forwarded-For': '127.0.0.1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
devServer: {
|
||||
port: 4321,
|
||||
port: 3000,
|
||||
},
|
||||
|
||||
css: ['@/assets/main.css'],
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"@vue/apollo-composable": "4.2.2",
|
||||
"@vueuse/components": "13.6.0",
|
||||
"@vueuse/integrations": "13.6.0",
|
||||
"ajv": "8.17.1",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"crypto-js": "4.2.0",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useQuery } from '@vue/apollo-composable';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { Dialog, Button, Input } from '@unraid/ui';
|
||||
import SsoButtonCe from '~/components/SsoButton.ce.vue';
|
||||
import { SERVER_INFO_QUERY } from './login.query';
|
||||
|
||||
@@ -10,10 +12,81 @@ const { result } = useQuery(SERVER_INFO_QUERY);
|
||||
|
||||
const serverName = computed(() => result.value?.info?.os?.hostname || 'UNRAID');
|
||||
const serverComment = computed(() => result.value?.vars?.comment || '');
|
||||
|
||||
const showDebugModal = ref(false);
|
||||
const debugData = ref<{ username: string; password: string; timestamp: string } | null>(null);
|
||||
const cliToken = ref('');
|
||||
const cliOutput = ref('');
|
||||
const isExecutingCli = ref(false);
|
||||
|
||||
// Check for token in URL hash on mount (keeps token out of server logs)
|
||||
const route = useRoute();
|
||||
onMounted(() => {
|
||||
// Check hash first (preferred), then query params (fallback)
|
||||
const hashParams = new URLSearchParams(window.location.hash.slice(1));
|
||||
const tokenFromHash = hashParams.get('token');
|
||||
const tokenFromQuery = route.query.token as string;
|
||||
|
||||
const token = tokenFromHash || tokenFromQuery;
|
||||
if (token) {
|
||||
cliToken.value = token;
|
||||
}
|
||||
});
|
||||
|
||||
const handleFormSubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
const form = event.target as HTMLFormElement;
|
||||
const formData = new FormData(form);
|
||||
|
||||
const password = formData.get('password') as string;
|
||||
|
||||
debugData.value = {
|
||||
username: formData.get('username') as string,
|
||||
password: password,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Clear the token field - it expects a JWT token, not a password
|
||||
cliToken.value = '';
|
||||
|
||||
showDebugModal.value = true;
|
||||
};
|
||||
|
||||
const executeCliCommand = async () => {
|
||||
if (!cliToken.value.trim()) {
|
||||
cliOutput.value = JSON.stringify({ error: 'Please enter a token', valid: false }, null, 2);
|
||||
return;
|
||||
}
|
||||
|
||||
isExecutingCli.value = true;
|
||||
try {
|
||||
const data = await $fetch('/api/debug/validate-token', {
|
||||
method: 'POST',
|
||||
body: { token: cliToken.value },
|
||||
});
|
||||
|
||||
// Format the output nicely
|
||||
if (data.success && 'stdout' in data && typeof data.stdout === 'object') {
|
||||
cliOutput.value = JSON.stringify(data.stdout, null, 2);
|
||||
} else if ('stdout' in data && data.stdout) {
|
||||
cliOutput.value = typeof data.stdout === 'string' ? data.stdout : JSON.stringify(data.stdout, null, 2);
|
||||
} else {
|
||||
cliOutput.value = JSON.stringify(data, null, 2);
|
||||
}
|
||||
} catch (error) {
|
||||
cliOutput.value = JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
valid: false
|
||||
}, null, 2);
|
||||
} finally {
|
||||
isExecutingCli.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="login" class="shadow">
|
||||
<div class="min-h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<section id="login" class="shadow">
|
||||
<div class="logo angle">
|
||||
<div class="wordmark">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 222.4 39" class="Nav__logo--white">
|
||||
@@ -33,7 +106,7 @@ const serverComment = computed(() => result.value?.vars?.comment || '');
|
||||
</div>
|
||||
|
||||
<div class="form">
|
||||
<form action="/login" method="POST">
|
||||
<form action="/login" method="POST" @submit="handleFormSubmit">
|
||||
<p>
|
||||
<input
|
||||
name="username"
|
||||
@@ -58,6 +131,50 @@ const serverComment = computed(() => result.value?.vars?.comment || '');
|
||||
<a href="https://docs.unraid.net/go/lost-root-password/" target="_blank" class="password-recovery">{{ t('Password recovery') }}</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Debug Dialog -->
|
||||
<Dialog
|
||||
v-model="showDebugModal"
|
||||
title="SSO Debug Tool"
|
||||
description="Debug SSO configurations and validate tokens"
|
||||
:show-footer="false"
|
||||
size="lg"
|
||||
>
|
||||
<div class="space-y-6 p-4">
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2 text-sm">Form Data Submitted:</h3>
|
||||
<pre class="bg-muted p-3 rounded text-xs overflow-x-auto max-h-32 overflow-y-auto whitespace-pre-wrap break-all">{{ JSON.stringify(debugData, null, 2) }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4">
|
||||
<h3 class="font-semibold mb-2 text-sm">JWT/OIDC Token Validation Tool:</h3>
|
||||
<p class="text-xs text-muted-foreground mb-3">Enter a JWT or OIDC session token to validate it using the CLI command</p>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<Input
|
||||
v-model="cliToken"
|
||||
type="text"
|
||||
placeholder="Enter JWT or OIDC session token"
|
||||
class="w-full break-all overflow-hidden"
|
||||
style="word-break: break-all; overflow-wrap: break-word;"
|
||||
/>
|
||||
<Button
|
||||
:disabled="isExecutingCli"
|
||||
class="w-full sm:w-auto"
|
||||
@click="executeCliCommand"
|
||||
>
|
||||
{{ isExecutingCli ? 'Validating...' : 'Validate Token' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="cliOutput" class="mt-4">
|
||||
<h4 class="font-semibold mb-2 text-sm">CLI Output:</h4>
|
||||
<pre class="bg-muted p-3 rounded text-xs overflow-x-auto max-h-48 overflow-y-auto whitespace-pre-wrap break-all">{{ cliOutput }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
/************************
|
||||
@@ -78,6 +195,11 @@ body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:root.dark body {
|
||||
background: #111827;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
a {
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
@@ -91,11 +213,22 @@ a:hover {
|
||||
h1 {
|
||||
font-size: 1.8em;
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
:root.dark h1 {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 0.8em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.8em;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
:root.dark h2 {
|
||||
color: #d1d5db;
|
||||
}
|
||||
.button {
|
||||
color: #ff8c2f;
|
||||
@@ -151,16 +284,30 @@ h2 {
|
||||
textarea {
|
||||
font-family: clear-sans, sans-serif;
|
||||
font-size: 0.875rem;
|
||||
background-color: #f2f2f2;
|
||||
background-color: #f3f4f6;
|
||||
color: #111827;
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
border: 2px solid #ccc;
|
||||
border: 2px solid #d1d5db;
|
||||
padding: 0.75rem 1rem;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
border-radius: 0;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
:root.dark [type='email'],
|
||||
:root.dark [type='number'],
|
||||
:root.dark [type='password'],
|
||||
:root.dark [type='search'],
|
||||
:root.dark [type='tel'],
|
||||
:root.dark [type='text'],
|
||||
:root.dark [type='url'],
|
||||
:root.dark textarea {
|
||||
background-color: #1f2937;
|
||||
color: #f3f4f6;
|
||||
border-color: #4b5563;
|
||||
}
|
||||
[type='email']:active,
|
||||
[type='email']:focus,
|
||||
[type='number']:active,
|
||||
@@ -192,6 +339,10 @@ textarea:focus {
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
:root.dark #login {
|
||||
background: #1f2937;
|
||||
}
|
||||
#login::after {
|
||||
content: '';
|
||||
clear: both;
|
||||
@@ -294,6 +445,10 @@ textarea:focus {
|
||||
body {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
:root.dark body {
|
||||
background: #111827;
|
||||
}
|
||||
[type='email'],
|
||||
[type='number'],
|
||||
[type='password'],
|
||||
|
||||
84
web/server/api/debug/validate-token.post.ts
Normal file
84
web/server/api/debug/validate-token.post.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { defineEventHandler, readBody } from 'h3';
|
||||
import { exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
const { token } = body;
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
error: 'Token is required',
|
||||
success: false
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Execute the Unraid API CLI command using command:raw to avoid build output
|
||||
const cliCommand = `cd ../api && pnpm command sso validate-token "${token}" 2>&1`;
|
||||
const { stdout, stderr } = await execAsync(cliCommand, {
|
||||
timeout: 30000,
|
||||
env: { ...process.env, NODE_ENV: 'production' } // Suppress debug output
|
||||
});
|
||||
|
||||
// Extract JSON from the output (last line that looks like JSON)
|
||||
const lines = stdout.trim().split('\n');
|
||||
let parsedOutput = null;
|
||||
|
||||
// Look for JSON output from the end
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i].trim();
|
||||
if (line.startsWith('{') && line.endsWith('}')) {
|
||||
try {
|
||||
parsedOutput = JSON.parse(line);
|
||||
break;
|
||||
} catch {
|
||||
// Continue looking
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsedOutput) {
|
||||
parsedOutput = stdout;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stdout: parsedOutput,
|
||||
stderr,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
} catch (execError) {
|
||||
// Extract JSON from error output
|
||||
const error = execError as { stdout?: string; stderr?: string; message?: string };
|
||||
const output = error.stdout || '';
|
||||
const lines = output.trim().split('\n');
|
||||
let parsedOutput = null;
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i].trim();
|
||||
if (line.startsWith('{') && line.endsWith('}')) {
|
||||
try {
|
||||
parsedOutput = JSON.parse(line);
|
||||
break;
|
||||
} catch {
|
||||
// Continue looking
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsedOutput) {
|
||||
parsedOutput = output;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Command failed',
|
||||
stdout: parsedOutput,
|
||||
stderr: error.stderr || '',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user