fix(oidc): normalize OIDC_SCOPE to handle whitespace issues (#1060)
Adds automatic scope normalization to prevent URL encoding issues when OIDC_SCOPE contains extra whitespace, tabs, or newlines. This addresses issue #1056 where spaces in the scope value could cause authentication failures in some environments. Changes: - Added normalizeScope() function to trim and collapse whitespace - Automatically adds 'openid' scope if missing with warning - Updated both single and multi-provider configurations - Added comprehensive tests for scope normalization edge cases - Added service tests to verify authorization URL construction - Updated documentation with scope formatting guidance Fixes #1056
This commit is contained in:
parent
cfe943b475
commit
93bcbc0485
4 changed files with 293 additions and 5 deletions
|
|
@ -6,6 +6,21 @@ function parseCommaSeparated(value) {
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeScope(scope) {
|
||||||
|
if (!scope) return 'openid profile email';
|
||||||
|
|
||||||
|
const normalized = scope.trim().split(/\s+/).filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
if (!normalized.includes('openid')) {
|
||||||
|
console.warn(
|
||||||
|
`OIDC scope does not include 'openid'. Adding it automatically. Original scope: "${scope}"`
|
||||||
|
);
|
||||||
|
return `openid ${normalized}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
function loadProvidersFromEnv() {
|
function loadProvidersFromEnv() {
|
||||||
if (process.env.OIDC_ENABLED !== 'true') {
|
if (process.env.OIDC_ENABLED !== 'true') {
|
||||||
console.log(
|
console.log(
|
||||||
|
|
@ -24,9 +39,7 @@ function loadProvidersFromEnv() {
|
||||||
issuer: process.env[`OIDC_PROVIDER_${i}_ISSUER`],
|
issuer: process.env[`OIDC_PROVIDER_${i}_ISSUER`],
|
||||||
clientId: process.env[`OIDC_PROVIDER_${i}_CLIENT_ID`],
|
clientId: process.env[`OIDC_PROVIDER_${i}_CLIENT_ID`],
|
||||||
clientSecret: process.env[`OIDC_PROVIDER_${i}_CLIENT_SECRET`],
|
clientSecret: process.env[`OIDC_PROVIDER_${i}_CLIENT_SECRET`],
|
||||||
scope:
|
scope: normalizeScope(process.env[`OIDC_PROVIDER_${i}_SCOPE`]),
|
||||||
process.env[`OIDC_PROVIDER_${i}_SCOPE`] ||
|
|
||||||
'openid profile email',
|
|
||||||
autoProvision:
|
autoProvision:
|
||||||
process.env[`OIDC_PROVIDER_${i}_AUTO_PROVISION`] !== 'false',
|
process.env[`OIDC_PROVIDER_${i}_AUTO_PROVISION`] !== 'false',
|
||||||
adminEmailDomains: parseCommaSeparated(
|
adminEmailDomains: parseCommaSeparated(
|
||||||
|
|
@ -63,7 +76,7 @@ function loadProvidersFromEnv() {
|
||||||
issuer: process.env.OIDC_ISSUER_URL,
|
issuer: process.env.OIDC_ISSUER_URL,
|
||||||
clientId: process.env.OIDC_CLIENT_ID,
|
clientId: process.env.OIDC_CLIENT_ID,
|
||||||
clientSecret: process.env.OIDC_CLIENT_SECRET,
|
clientSecret: process.env.OIDC_CLIENT_SECRET,
|
||||||
scope: process.env.OIDC_SCOPE || 'openid profile email',
|
scope: normalizeScope(process.env.OIDC_SCOPE),
|
||||||
autoProvision: process.env.OIDC_AUTO_PROVISION !== 'false',
|
autoProvision: process.env.OIDC_AUTO_PROVISION !== 'false',
|
||||||
adminEmailDomains: parseCommaSeparated(
|
adminEmailDomains: parseCommaSeparated(
|
||||||
process.env.OIDC_ADMIN_EMAIL_DOMAINS
|
process.env.OIDC_ADMIN_EMAIL_DOMAINS
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,60 @@ describe('OIDC Provider Configuration', () => {
|
||||||
expect(provider.scope).toBe('openid profile email groups');
|
expect(provider.scope).toBe('openid profile email groups');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should normalize scope with extra whitespace', () => {
|
||||||
|
process.env.OIDC_ENABLED = 'true';
|
||||||
|
process.env.OIDC_PROVIDER_NAME = 'Test';
|
||||||
|
process.env.OIDC_PROVIDER_SLUG = 'test';
|
||||||
|
process.env.OIDC_ISSUER_URL = 'https://auth.example.com';
|
||||||
|
process.env.OIDC_CLIENT_ID = 'test-id';
|
||||||
|
process.env.OIDC_CLIENT_SECRET = 'test-secret';
|
||||||
|
process.env.OIDC_SCOPE = ' openid profile email ';
|
||||||
|
|
||||||
|
providerConfig.reloadProviders();
|
||||||
|
const provider = providerConfig.getProvider('test');
|
||||||
|
|
||||||
|
expect(provider.scope).toBe('openid profile email');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add openid scope if missing', () => {
|
||||||
|
const consoleWarnSpy = jest
|
||||||
|
.spyOn(console, 'warn')
|
||||||
|
.mockImplementation();
|
||||||
|
|
||||||
|
process.env.OIDC_ENABLED = 'true';
|
||||||
|
process.env.OIDC_PROVIDER_NAME = 'Test';
|
||||||
|
process.env.OIDC_PROVIDER_SLUG = 'test';
|
||||||
|
process.env.OIDC_ISSUER_URL = 'https://auth.example.com';
|
||||||
|
process.env.OIDC_CLIENT_ID = 'test-id';
|
||||||
|
process.env.OIDC_CLIENT_SECRET = 'test-secret';
|
||||||
|
process.env.OIDC_SCOPE = 'profile email';
|
||||||
|
|
||||||
|
providerConfig.reloadProviders();
|
||||||
|
const provider = providerConfig.getProvider('test');
|
||||||
|
|
||||||
|
expect(provider.scope).toBe('openid profile email');
|
||||||
|
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("does not include 'openid'")
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleWarnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle scope with tabs and newlines', () => {
|
||||||
|
process.env.OIDC_ENABLED = 'true';
|
||||||
|
process.env.OIDC_PROVIDER_NAME = 'Test';
|
||||||
|
process.env.OIDC_PROVIDER_SLUG = 'test';
|
||||||
|
process.env.OIDC_ISSUER_URL = 'https://auth.example.com';
|
||||||
|
process.env.OIDC_CLIENT_ID = 'test-id';
|
||||||
|
process.env.OIDC_CLIENT_SECRET = 'test-secret';
|
||||||
|
process.env.OIDC_SCOPE = 'openid\tprofile\nemail';
|
||||||
|
|
||||||
|
providerConfig.reloadProviders();
|
||||||
|
const provider = providerConfig.getProvider('test');
|
||||||
|
|
||||||
|
expect(provider.scope).toBe('openid profile email');
|
||||||
|
});
|
||||||
|
|
||||||
it('should parse admin email domains', () => {
|
it('should parse admin email domains', () => {
|
||||||
process.env.OIDC_ENABLED = 'true';
|
process.env.OIDC_ENABLED = 'true';
|
||||||
process.env.OIDC_PROVIDER_NAME = 'Google';
|
process.env.OIDC_PROVIDER_NAME = 'Google';
|
||||||
|
|
@ -227,6 +281,32 @@ describe('OIDC Provider Configuration', () => {
|
||||||
expect(corp.autoProvision).toBe(false);
|
expect(corp.autoProvision).toBe(false);
|
||||||
expect(corp.adminEmailDomains).toEqual(['corp.com']);
|
expect(corp.adminEmailDomains).toEqual(['corp.com']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should normalize scopes in multi-provider configuration', () => {
|
||||||
|
process.env.OIDC_ENABLED = 'true';
|
||||||
|
|
||||||
|
process.env.OIDC_PROVIDER_1_NAME = 'Google';
|
||||||
|
process.env.OIDC_PROVIDER_1_SLUG = 'google';
|
||||||
|
process.env.OIDC_PROVIDER_1_ISSUER = 'https://accounts.google.com';
|
||||||
|
process.env.OIDC_PROVIDER_1_CLIENT_ID = 'google-id';
|
||||||
|
process.env.OIDC_PROVIDER_1_CLIENT_SECRET = 'google-secret';
|
||||||
|
process.env.OIDC_PROVIDER_1_SCOPE = ' openid profile email ';
|
||||||
|
|
||||||
|
process.env.OIDC_PROVIDER_2_NAME = 'Okta';
|
||||||
|
process.env.OIDC_PROVIDER_2_SLUG = 'okta';
|
||||||
|
process.env.OIDC_PROVIDER_2_ISSUER = 'https://company.okta.com';
|
||||||
|
process.env.OIDC_PROVIDER_2_CLIENT_ID = 'okta-id';
|
||||||
|
process.env.OIDC_PROVIDER_2_CLIENT_SECRET = 'okta-secret';
|
||||||
|
process.env.OIDC_PROVIDER_2_SCOPE = 'openid profile email groups';
|
||||||
|
|
||||||
|
providerConfig.reloadProviders();
|
||||||
|
|
||||||
|
const google = providerConfig.getProvider('google');
|
||||||
|
const okta = providerConfig.getProvider('okta');
|
||||||
|
|
||||||
|
expect(google.scope).toBe('openid profile email');
|
||||||
|
expect(okta.scope).toBe('openid profile email groups');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getProvider', () => {
|
describe('getProvider', () => {
|
||||||
|
|
|
||||||
178
backend/tests/unit/modules/oidc/service.test.js
Normal file
178
backend/tests/unit/modules/oidc/service.test.js
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
const { Issuer } = require('openid-client');
|
||||||
|
const oidcService = require('../../../../modules/oidc/service');
|
||||||
|
const providerConfig = require('../../../../modules/oidc/providerConfig');
|
||||||
|
const stateManager = require('../../../../modules/oidc/stateManager');
|
||||||
|
|
||||||
|
jest.mock('../../../../modules/oidc/providerConfig');
|
||||||
|
jest.mock('../../../../modules/oidc/stateManager');
|
||||||
|
|
||||||
|
describe('OIDC Service - Authorization URL Construction', () => {
|
||||||
|
let originalEnv;
|
||||||
|
let mockIssuer;
|
||||||
|
let mockClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalEnv = { ...process.env };
|
||||||
|
process.env.BASE_URL = 'https://todo.example.com';
|
||||||
|
|
||||||
|
mockClient = {
|
||||||
|
authorizationUrl: jest.fn(),
|
||||||
|
callback: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockIssuer = {
|
||||||
|
Client: jest.fn(() => mockClient),
|
||||||
|
metadata: {
|
||||||
|
issuer: 'https://auth.example.com',
|
||||||
|
authorization_endpoint: 'https://auth.example.com/authorize',
|
||||||
|
token_endpoint: 'https://auth.example.com/token',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.spyOn(Issuer, 'discover').mockResolvedValue(mockIssuer);
|
||||||
|
|
||||||
|
oidcService.clearIssuerCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = originalEnv;
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
oidcService.clearIssuerCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initiateAuthFlow with scope containing spaces', () => {
|
||||||
|
it('should properly encode scope with spaces in authorization URL', async () => {
|
||||||
|
const mockProvider = {
|
||||||
|
slug: 'test-provider',
|
||||||
|
name: 'Test Provider',
|
||||||
|
issuer: 'https://auth.example.com',
|
||||||
|
clientId: 'test-client-id',
|
||||||
|
clientSecret: 'test-client-secret',
|
||||||
|
scope: 'openid profile email',
|
||||||
|
};
|
||||||
|
|
||||||
|
providerConfig.getProvider.mockReturnValue(mockProvider);
|
||||||
|
|
||||||
|
stateManager.createState.mockResolvedValue({
|
||||||
|
state: 'test-state-123',
|
||||||
|
nonce: 'test-nonce-456',
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockAuthUrl =
|
||||||
|
'https://auth.example.com/authorize?client_id=test-client-id&scope=openid%20profile%20email&response_type=code&redirect_uri=https%3A%2F%2Ftodo.example.com%2Fapi%2Foidc%2Fcallback%2Ftest-provider&state=test-state-123&nonce=test-nonce-456';
|
||||||
|
|
||||||
|
mockClient.authorizationUrl.mockReturnValue(mockAuthUrl);
|
||||||
|
|
||||||
|
const result = await oidcService.initiateAuthFlow('test-provider');
|
||||||
|
|
||||||
|
expect(mockClient.authorizationUrl).toHaveBeenCalledWith({
|
||||||
|
scope: 'openid profile email',
|
||||||
|
state: 'test-state-123',
|
||||||
|
nonce: 'test-nonce-456',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.authUrl).toContain('scope=openid%20profile%20email');
|
||||||
|
|
||||||
|
expect(result.authUrl).not.toContain('scope=openid profile email');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle scope with plus signs correctly', async () => {
|
||||||
|
const mockProvider = {
|
||||||
|
slug: 'test-provider',
|
||||||
|
name: 'Test Provider',
|
||||||
|
issuer: 'https://auth.example.com',
|
||||||
|
clientId: 'test-client-id',
|
||||||
|
clientSecret: 'test-client-secret',
|
||||||
|
scope: 'openid+profile+email',
|
||||||
|
};
|
||||||
|
|
||||||
|
providerConfig.getProvider.mockReturnValue(mockProvider);
|
||||||
|
|
||||||
|
stateManager.createState.mockResolvedValue({
|
||||||
|
state: 'test-state-123',
|
||||||
|
nonce: 'test-nonce-456',
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockAuthUrl =
|
||||||
|
'https://auth.example.com/authorize?client_id=test-client-id&scope=openid%2Bprofile%2Bemail&response_type=code&redirect_uri=https%3A%2F%2Ftodo.example.com%2Fapi%2Foidc%2Fcallback%2Ftest-provider&state=test-state-123&nonce=test-nonce-456';
|
||||||
|
|
||||||
|
mockClient.authorizationUrl.mockReturnValue(mockAuthUrl);
|
||||||
|
|
||||||
|
const result = await oidcService.initiateAuthFlow('test-provider');
|
||||||
|
|
||||||
|
expect(mockClient.authorizationUrl).toHaveBeenCalledWith({
|
||||||
|
scope: 'openid+profile+email',
|
||||||
|
state: 'test-state-123',
|
||||||
|
nonce: 'test-nonce-456',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.authUrl).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle custom scopes with spaces', async () => {
|
||||||
|
const mockProvider = {
|
||||||
|
slug: 'test-provider',
|
||||||
|
name: 'Test Provider',
|
||||||
|
issuer: 'https://auth.example.com',
|
||||||
|
clientId: 'test-client-id',
|
||||||
|
clientSecret: 'test-client-secret',
|
||||||
|
scope: 'openid profile email groups offline_access',
|
||||||
|
};
|
||||||
|
|
||||||
|
providerConfig.getProvider.mockReturnValue(mockProvider);
|
||||||
|
|
||||||
|
stateManager.createState.mockResolvedValue({
|
||||||
|
state: 'test-state-123',
|
||||||
|
nonce: 'test-nonce-456',
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockAuthUrl =
|
||||||
|
'https://auth.example.com/authorize?client_id=test-client-id&scope=openid%20profile%20email%20groups%20offline_access&response_type=code&redirect_uri=https%3A%2F%2Ftodo.example.com%2Fapi%2Foidc%2Fcallback%2Ftest-provider&state=test-state-123&nonce=test-nonce-456';
|
||||||
|
|
||||||
|
mockClient.authorizationUrl.mockReturnValue(mockAuthUrl);
|
||||||
|
|
||||||
|
const result = await oidcService.initiateAuthFlow('test-provider');
|
||||||
|
|
||||||
|
expect(mockClient.authorizationUrl).toHaveBeenCalledWith({
|
||||||
|
scope: 'openid profile email groups offline_access',
|
||||||
|
state: 'test-state-123',
|
||||||
|
nonce: 'test-nonce-456',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.authUrl).toContain(
|
||||||
|
'scope=openid%20profile%20email%20groups%20offline_access'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle scope with leading/trailing spaces', async () => {
|
||||||
|
const mockProvider = {
|
||||||
|
slug: 'test-provider',
|
||||||
|
name: 'Test Provider',
|
||||||
|
issuer: 'https://auth.example.com',
|
||||||
|
clientId: 'test-client-id',
|
||||||
|
clientSecret: 'test-client-secret',
|
||||||
|
scope: ' openid profile email ',
|
||||||
|
};
|
||||||
|
|
||||||
|
providerConfig.getProvider.mockReturnValue(mockProvider);
|
||||||
|
|
||||||
|
stateManager.createState.mockResolvedValue({
|
||||||
|
state: 'test-state-123',
|
||||||
|
nonce: 'test-nonce-456',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockClient.authorizationUrl.mockReturnValue(
|
||||||
|
'https://auth.example.com/authorize?scope=openid%20profile%20email'
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await oidcService.initiateAuthFlow('test-provider');
|
||||||
|
|
||||||
|
const scopeArgument =
|
||||||
|
mockClient.authorizationUrl.mock.calls[0][0].scope;
|
||||||
|
|
||||||
|
expect(scopeArgument.trim()).toBe('openid profile email');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -169,11 +169,28 @@ OIDC_PROVIDER_3_AUTO_PROVISION=true
|
||||||
| `OIDC_ISSUER_URL` | Yes | - | OIDC discovery endpoint |
|
| `OIDC_ISSUER_URL` | Yes | - | OIDC discovery endpoint |
|
||||||
| `OIDC_CLIENT_ID` | Yes | - | OAuth client ID |
|
| `OIDC_CLIENT_ID` | Yes | - | OAuth client ID |
|
||||||
| `OIDC_CLIENT_SECRET` | Yes | - | OAuth client secret |
|
| `OIDC_CLIENT_SECRET` | Yes | - | OAuth client secret |
|
||||||
| `OIDC_SCOPE` | No | `openid profile email` | OAuth scopes |
|
| `OIDC_SCOPE` | No | `openid profile email` | OAuth scopes (space-separated) |
|
||||||
| `OIDC_AUTO_PROVISION` | No | `true` | Auto-create users on first login |
|
| `OIDC_AUTO_PROVISION` | No | `true` | Auto-create users on first login |
|
||||||
| `OIDC_ADMIN_EMAIL_DOMAINS` | No | - | Comma-separated domains for auto-admin |
|
| `OIDC_ADMIN_EMAIL_DOMAINS` | No | - | Comma-separated domains for auto-admin |
|
||||||
| `BASE_URL` | Yes | - | Tududi base URL (for OAuth callbacks) |
|
| `BASE_URL` | Yes | - | Tududi base URL (for OAuth callbacks) |
|
||||||
|
|
||||||
|
**Scope Formatting:**
|
||||||
|
|
||||||
|
The `OIDC_SCOPE` parameter accepts space-separated OAuth scopes. Tududi automatically normalizes the scope value by:
|
||||||
|
- Trimming leading/trailing whitespace
|
||||||
|
- Collapsing multiple spaces into single spaces
|
||||||
|
- Ensuring `openid` is always included (adding it if missing)
|
||||||
|
- Properly URL-encoding the scope in authorization requests
|
||||||
|
|
||||||
|
Examples of valid scope formats:
|
||||||
|
```bash
|
||||||
|
OIDC_SCOPE=openid profile email
|
||||||
|
OIDC_SCOPE="openid profile email groups"
|
||||||
|
OIDC_SCOPE=openid profile email # Extra spaces are automatically normalized
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Do not manually URL-encode the scope value (e.g., using `%20` or `+`). Use regular spaces - Tududi handles the encoding automatically.
|
||||||
|
|
||||||
**Important:** The `BASE_URL` variable must be set for OAuth redirects to work:
|
**Important:** The `BASE_URL` variable must be set for OAuth redirects to work:
|
||||||
```bash
|
```bash
|
||||||
BASE_URL=http://localhost:3002 # Development
|
BASE_URL=http://localhost:3002 # Development
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue