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:
Eli Bosley
2025-08-15 11:59:21 -04:00
committed by GitHub
parent 96c120f9b2
commit 979a267bc5
83 changed files with 7852 additions and 519 deletions

3
.gitignore vendored
View File

@@ -76,6 +76,9 @@ typescript
# Github actions
RELEASE_NOTES.md
# Test backups
api/dev/configs/api.json.backup
# Docker Deploy Folder
deploy/*
!deploy/.gitkeep

View File

@@ -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
View File

@@ -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
View 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`

View File

@@ -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"
]
}

View File

@@ -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
View 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"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@@ -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

View 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
![Login Page with SSO Options](./images/sso-with-options.png)
_Screenshot: Login page showing traditional login form with SSO options - "Login With Unraid.net" and "Sign in with Google" buttons_
The interface includes:
- **Provider tabs**: Each configured provider (Unraid.net, Google, etc.) appears as a tab
- **Add Provider button**: Click the **+** button to add new providers
- **Authorization Mode dropdown**: Toggle between "simple" and "advanced" modes
- **Simple Authorization section**: Configure allowed email domains and specific addresses
- **Add Item buttons**: Click to add multiple authorization rules
## Understanding Authorization Modes
The interface provides two authorization modes:
### Simple Mode (Recommended)
Simple mode is the easiest way to configure authorization. You can:
- Allow specific email domains (e.g., @company.com)
- Allow specific email addresses
- Configure who can access your Unraid server with minimal setup
**When to use Simple Mode:**
- You want to allow all users from your company domain
- You have a small list of specific users
- You're new to OIDC configuration
<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
![Authorization Rules Configuration](./images/advanced-rules.png)
_Screenshot: Advanced authorization rules showing JWT claim configuration with email endsWith operator for domain-based access control_
### Simple Mode Examples
#### Allow Company Domain
In Simple Authorization:
- **Allowed Email Domains**: Enter `company.com`
- This allows anyone with @company.com email
#### Allow Specific Users
- **Specific Email Addresses**: Add individual emails
- Click **Add Item** to add multiple addresses
<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
![Login Page with SSO Buttons](./images/sso-with-options.png)
_Screenshot: Unraid login page displaying both traditional username/password authentication and SSO options with customized provider buttons_
1. Save your provider configuration
2. Log out (if logged in)
3. Navigate to the login page
4. Your configured provider button should appear
5. Click to test the login flow
## Troubleshooting
### Common Issues
#### "Provider not found" error
- Ensure the Issuer URL is correct
- Check that the provider supports OIDC discovery (/.well-known/openid-configuration)
#### "Authorization failed"
- In Simple Mode: Check email domains are entered correctly (without @)
- In Advanced Mode:
- Verify claim names match exactly what your provider sends
- Check if Authorization Rule Mode is set correctly (OR vs AND)
- Ensure all required claims are present in the token
- Enable debug logging to see actual claims and rule evaluation
#### "Invalid redirect URI"
- Ensure the redirect URI in your provider matches exactly
- Include the port number (:3001)
- Use HTTP for local, HTTPS for production
#### Cannot see login button
- Check that at least one authorization rule is configured
- Verify the provider is enabled/saved
### Debug Mode
To troubleshoot issues:
1. Enable debug logging:
```bash
LOG_LEVEL=debug unraid-api start --debug
```
2. Check logs for:
- Received claims from provider
- Authorization rule evaluation
- Token validation errors
## Security Best Practices
1. **Always use HTTPS in production** - OAuth requires secure connections
2. **Use Simple Mode for authorization** - Prevents overly accepting configurations and reduces misconfiguration risks
3. **Be specific with authorization** - Don't use overly broad rules
4. **Rotate secrets regularly** - Update client secrets periodically
5. **Test thoroughly** - Verify only intended users can access
## Need Help?
- Check provider's OIDC documentation
- Review Unraid API logs for detailed error messages
- Ensure your provider supports standard OIDC discovery
- Verify network connectivity between Unraid and provider
## Provider-Specific Setup
### Unraid.net Provider
The Unraid.net provider is built-in and pre-configured. You only need to configure authorization rules in the interface.
**Configuration:**
- **Issuer URL**: Pre-configured (built-in provider)
- **Client ID/Secret**: Pre-configured (built-in provider)
- **Redirect URI**: `http://YOUR_UNRAID_IP:3001/graphql/api/auth/oidc/callback`
:::warning[Security Notice]
**Always use HTTPS for production redirect URIs!** The examples above use HTTP for initial setup and testing only. In production environments, you MUST use HTTPS (e.g., `https://YOUR_UNRAID_IP:3001/graphql/api/auth/oidc/callback`) to ensure secure communication and prevent credential interception. Most OIDC providers will reject HTTP redirect URIs for security reasons.
:::
Configure authorization rules using Simple Mode (allowed email domains/addresses) or Advanced Mode for complex requirements.
### Google
Set up OAuth 2.0 credentials in [Google Cloud Console](https://console.cloud.google.com/):
1. Go to **APIs & Services****Credentials**
2. Click **Create Credentials****OAuth client ID**
3. Choose **Web application** as the application type
4. Add your redirect URI to **Authorized redirect URIs**
5. Configure the OAuth consent screen if prompted
**Configuration:**
- **Issuer URL**: `https://accounts.google.com`
- **Client ID/Secret**: From your OAuth 2.0 client credentials
- **Required Scopes**: `openid`, `profile`, `email`
- **Redirect URI**: `http://YOUR_UNRAID_IP:3001/graphql/api/auth/oidc/callback`
:::warning[Google Domain Requirements]
**Google requires valid domain names for OAuth redirect URIs.** Local IP addresses and `.local` domains are not accepted. To use Google OAuth with your Unraid server, you'll need:
- **Option 1: Reverse Proxy** - Set up a reverse proxy (like NGINX Proxy Manager or Traefik) with a valid domain name pointing to your Unraid API
- **Option 2: Tailscale** - Use Tailscale to get a valid `*.ts.net` domain that Google will accept
- **Option 3: Dynamic DNS** - Use a DDNS service to get a public domain name for your server
Remember to update your redirect URI in both Google Cloud Console and your Unraid OIDC configuration to use the valid domain.
:::
For Google Workspace domains, use Advanced Mode with the `hd` claim to restrict access to your organization's domain.
### Authelia
Configure OIDC client in your Authelia `configuration.yml` with client ID `unraid-api` and generate a hashed secret using the Authelia hash-password command.
**Configuration:**
- **Issuer URL**: `https://auth.yourdomain.com`
- **Client ID**: `unraid-api` (or as configured in Authelia)
- **Client Secret**: Your unhashed secret
- **Required Scopes**: `openid`, `profile`, `email`, `groups`
- **Redirect URI**: `http://YOUR_UNRAID_IP:3001/graphql/api/auth/oidc/callback`
Use Advanced Mode with `groups` claim for group-based authorization.
### Microsoft/Azure AD
Register a new app in [Azure Portal](https://portal.azure.com/) under Azure Active Directory → App registrations. Note the Application ID, create a client secret, and note your tenant ID.
**Configuration:**
- **Issuer URL**: `https://login.microsoftonline.com/YOUR_TENANT_ID/v2.0`
- **Client ID**: Your Application (client) ID
- **Client Secret**: Generated client secret
- **Required Scopes**: `openid`, `profile`, `email`
- **Redirect URI**: `http://YOUR_UNRAID_IP:3001/graphql/api/auth/oidc/callback`
Authorization rules can be configured in the interface using email domains or advanced claims.
### Keycloak
Create a new confidential client in Keycloak Admin Console with `openid-connect` protocol and copy the client secret from the Credentials tab.
**Configuration:**
- **Issuer URL**: `https://keycloak.example.com/realms/YOUR_REALM`
- **Client ID**: `unraid-api` (or as configured in Keycloak)
- **Client Secret**: From Keycloak Credentials tab
- **Required Scopes**: `openid`, `profile`, `email`
- **Redirect URI**: `http://YOUR_UNRAID_IP:3001/graphql/api/auth/oidc/callback`
For role-based authorization, use Advanced Mode with `realm_access.roles` or `resource_access` claims.
### Authentik
Create a new OAuth2/OpenID Provider in Authentik, then create an Application and link it to the provider.
**Configuration:**
- **Issuer URL**: `https://authentik.example.com/application/o/unraid-api/`
- **Client ID**: From Authentik provider configuration
- **Client Secret**: From Authentik provider configuration
- **Required Scopes**: `openid`, `profile`, `email`
- **Redirect URI**: `http://YOUR_UNRAID_IP:3001/graphql/api/auth/oidc/callback`
Authorization rules can be configured in the interface.
### Okta
Create a new OIDC Web Application in Okta Admin Console and assign appropriate users or groups.
**Configuration:**
- **Issuer URL**: `https://YOUR_DOMAIN.okta.com`
- **Client ID**: From Okta application configuration
- **Client Secret**: From Okta application configuration
- **Required Scopes**: `openid`, `profile`, `email`
- **Redirect URI**: `http://YOUR_UNRAID_IP:3001/graphql/api/auth/oidc/callback`
Authorization rules can be configured in the interface using email domains or advanced claims.

View File

@@ -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!

View File

@@ -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",

View File

@@ -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 /),
}),
})
);
});

View File

@@ -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

View 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();

View File

@@ -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,

View File

@@ -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] ?? {};

View File

@@ -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>;

View File

@@ -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
}
}
`);

View File

@@ -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}`);
}
}
}

View File

@@ -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',

View 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);
});
});
});

View 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>,
});

View File

@@ -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))}`

View File

@@ -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: [

View File

@@ -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;
}

View File

@@ -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 {}

View File

@@ -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();
}
}

View File

@@ -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',

View File

@@ -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
}

File diff suppressed because it is too large Load Diff

View 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`;
}
}

File diff suppressed because it is too large Load Diff

View 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;
}

View File

@@ -0,0 +1,10 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class OidcSessionValidation {
@Field(() => Boolean)
valid!: boolean;
@Field(() => String, { nullable: true })
username?: string;
}

View File

@@ -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();
});
});
});

View 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;
}
}
}

View File

@@ -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);
});
});
});

View 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`);
}
}
}

View File

@@ -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
);
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View 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 {}

View 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);
}
}

View File

@@ -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,
});

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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();
}
}
}

View File

@@ -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],
})

View File

@@ -45,5 +45,5 @@ describe('CloudService.hardCheckCloud (integration)', () => {
}
throw error;
}
});
}, { timeout: 10000 });
});

View File

@@ -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",

View File

@@ -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
View File

@@ -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

View File

@@ -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",

View 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>

View File

@@ -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"

View 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>

View File

@@ -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 &&

View File

@@ -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"

View File

@@ -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"

View 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,
};

View File

@@ -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),

View File

@@ -6,6 +6,7 @@ export * from '@/components/ui';
// JsonForms
export * from '@/forms/renderers';
export * from '@/forms/config';
// Lib
export * from '@/lib/utils';

View File

@@ -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');
});
});

View File

@@ -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

View File

@@ -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"
/>

View File

@@ -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>

View 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
}
}
}
}
`;

View 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
}
}
`);

View 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>

View 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>

View 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,
};
}

View 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,
};
}

View File

@@ -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.
*/

View File

@@ -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>;

View File

@@ -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'],

View File

@@ -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",

View File

@@ -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'],

View 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()
};
}
});