feat: Remove OIDC and SSO support

Existing SSO accounts have no local password, so the migration
deactivates them before dropping is_sso_user and the
identity_provider_user table.
This commit is contained in:
Daniel Hiller
2026-08-07 19:15:43 +02:00
parent a1f0a2b3fa
commit 36aa732fec
128 changed files with 121 additions and 1993 deletions
+1 -16
View File
@@ -97,11 +97,10 @@
* message:
* type: string
* enum:
* - Use single sign-on
* - Terms acceptance required
* - Admin login required to initialize instance
* description: Specific error message
* example: Use single sign-on
* example: Terms acceptance required
* security: []
*/
@@ -120,9 +119,6 @@ const Errors = {
INVALID_PASSWORD: {
invalidPassword: 'Invalid password',
},
USE_SINGLE_SIGN_ON: {
useSingleSignOn: 'Use single sign-on',
},
TERMS_ACCEPTANCE_REQUIRED: {
termsAcceptanceRequired: 'Terms acceptance required',
},
@@ -156,9 +152,6 @@ module.exports = {
invalidPassword: {
responseType: 'unauthorized',
},
useSingleSignOn: {
responseType: 'forbidden',
},
termsAcceptanceRequired: {
responseType: 'forbidden',
},
@@ -168,10 +161,6 @@ module.exports = {
},
async fn(inputs) {
if (sails.config.custom.oidcEnforced) {
throw Errors.USE_SINGLE_SIGN_ON;
}
const remoteAddress = getRemoteAddress(this.req);
const user = await User.qm.getOneActiveByEmailOrUsername(inputs.emailOrUsername);
@@ -185,10 +174,6 @@ module.exports = {
: Errors.INVALID_CREDENTIALS;
}
if (user.isSsoUser) {
throw Errors.USE_SINGLE_SIGN_ON;
}
const isPasswordValid = await bcrypt.compare(inputs.password, user.password);
if (!isPasswordValid) {
@@ -1,223 +0,0 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
const Errors = {
NOT_ENOUGH_RIGHTS: {
notEnoughRights: 'Not enough rights',
},
};
module.exports = {
inputs: {
code: {
type: 'string',
maxLength: 2048,
required: true,
},
nonce: {
type: 'string',
maxLength: 1024,
required: true,
},
},
exits: {
notEnoughRights: {
responseType: 'forbidden',
},
},
async fn(inputs) {
if (!sails.config.custom.oidcDebug) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
const logs = ['🔐 Starting OIDC debug flow...', ''];
const client = await sails.hooks.oidc.getClient();
if (!client) {
logs.push('❌ OIDC client is not initialized.');
logs.push('💡 Hint: Check your OIDC issuer and client configuration.');
return {
item: null,
included: {
logs,
},
};
}
let tokenSet;
try {
logs.push('🔄 Exchanging authorization code...');
if (sails.config.custom.oidcUseOauthCallback) {
tokenSet = await client.oauthCallback(
sails.config.custom.oidcRedirectUri,
{
iss: sails.config.custom.oidcIssuer,
code: inputs.code,
},
{ nonce: inputs.nonce },
);
} else {
tokenSet = await client.callback(
sails.config.custom.oidcRedirectUri,
{
iss: sails.config.custom.oidcIssuer,
code: inputs.code,
},
{ nonce: inputs.nonce },
);
}
logs.push('✅ Authorization code exchanged successfully.', '');
} catch (error) {
logs.push('❌ Failed to exchange authorization code.');
logs.push(`💬 Reason: ${error.message || error.toString()}`);
logs.push('💡 Hint: Check redirect URI, client secret, and nonce handling.');
return {
item: null,
included: {
logs,
},
};
}
if (sails.config.custom.oidcClaimsSource === 'id_token') {
logs.push('📥 Extracting claims from ID token...');
try {
claims = tokenSet.claims();
logs.push('✅ Claims extracted successfully.', '');
} catch (error) {
logs.push('❌ Failed to extract user claims.');
logs.push(`💬 Reason: ${error.message || error.toString()}`);
return {
item: null,
included: {
logs,
},
};
}
} else {
logs.push('📥 Fetching claims from userinfo endpoint...');
try {
claims = await client.userinfo(tokenSet);
logs.push('✅ Claims fetched successfully.', '');
} catch (error) {
logs.push('❌ Failed to fetch user claims.');
if (error instanceof SyntaxError && error.message.includes('Unexpected token e in JSON')) {
logs.push('💬 Reason: Userinfo response is signed or not JSON.');
logs.push(
'💡 Hint: Try configuring userinfo signed response algorithm or switch to ID token claims.',
);
} else {
logs.push(`💬 Reason: ${error.message || error.toString()}`);
}
return {
item: null,
included: {
logs,
},
};
}
}
logs.push('📦 Raw claims received:', JSON.stringify(claims, null, 2), '');
logs.push('🧩 Evaluating claim mappings...', '');
const mappings = {
email: {
attribute: sails.config.custom.oidcEmailAttribute,
value: _.get(claims, sails.config.custom.oidcEmailAttribute),
},
name: {
attribute: sails.config.custom.oidcNameAttribute,
value: _.get(claims, sails.config.custom.oidcNameAttribute),
},
username: sails.config.custom.oidcIgnoreUsername
? undefined
: {
attribute: sails.config.custom.oidcUsernameAttribute,
value: _.get(claims, sails.config.custom.oidcUsernameAttribute),
},
roles: sails.config.custom.oidcIgnoreRoles
? undefined
: {
attribute: sails.config.custom.oidcRolesAttribute,
value: _.get(claims, sails.config.custom.oidcRolesAttribute),
},
};
logs.push('📋 Mapping result:', JSON.stringify(mappings, null, 2), '');
if (!mappings.email.value) {
logs.push('❌ Email not resolved.');
logs.push('💡 Hint: Check email attribute mapping.', '');
}
if (!mappings.name.value) {
logs.push('❌ Name not resolved.');
logs.push('💡 Hint: Check name attribute mapping.', '');
}
if (!sails.config.custom.oidcIgnoreUsername) {
if (!mappings.username.value) {
logs.push('⚠️ Username not resolved.');
logs.push('💡 Hint: Check username attribute mapping.', '');
}
}
if (!sails.config.custom.oidcIgnoreRoles) {
if (!Array.isArray(mappings.roles.value) || mappings.roles.value.length === 0) {
logs.push('⚠️ Roles not resolved or empty.');
logs.push('💡 Hint: Check roles attribute mapping or IdP role configuration.', '');
} else {
logs.push('🎭 Resolving user role from OIDC roles...');
// Use a Set here to avoid quadratic time complexity
const claimsRolesSet = new Set(mappings.roles.value);
const foundRole = [User.Roles.ADMIN, User.Roles.PROJECT_OWNER, User.Roles.BOARD_USER].find(
(roleItem) => {
const configRoles = sails.config.custom[`oidc${_.upperFirst(roleItem)}Roles`];
if (configRoles.includes('*')) {
return true;
}
return configRoles.some((configRole) => claimsRolesSet.has(configRole));
},
);
if (foundRole) {
logs.push(`✅ Matched user role → ${_.lowerCase(foundRole)}`, '');
} else {
logs.push('⚠️ No user role matched configured OIDC roles.');
logs.push('💡 Hint: Check role matching settings.', '');
}
}
}
if (mappings.email.value && mappings.name.value) {
logs.push('🎉 OIDC debug completed successfully.');
} else {
logs.push('🛑 OIDC debug detected missing required attributes.');
}
return {
item: null,
included: {
logs,
},
};
},
};
@@ -1,271 +0,0 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
/**
* @swagger
* /access-tokens/exchange-with-oidc:
* post:
* summary: Exchange OIDC code for access token
* description: Exchanges an OIDC authorization code for an access token. Creates a user if they do not exist.
* tags:
* - Access Tokens
* operationId: exchangeForAccessTokenWithOidc
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - code
* - nonce
* properties:
* code:
* type: string
* maxLength: 2048
* description: Authorization code from OIDC provider
* example: abc123def456ghi789
* nonce:
* type: string
* maxLength: 1024
* description: Nonce value for OIDC security
* example: random-nonce-123456
* withHttpOnlyToken:
* type: boolean
* description: Whether to include HTTP-only authentication cookie
* example: true
* responses:
* 200:
* description: OIDC exchange successful
* content:
* application/json:
* schema:
* type: object
* required:
* - item
* properties:
* item:
* type: string
* description: Access token for API authentication
* example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ4...
* headers:
* Set-Cookie:
* description: HTTP-only authentication cookie (if `withHttpOnlyToken` is true)
* schema:
* type: string
* example: httpOnlyToken=29aa3e38-8d24-4029-9743-9cbcf0a0dd5c; HttpOnly; Secure; SameSite=Strict
* 400:
* $ref: '#/components/responses/ValidationError'
* 401:
* description: OIDC authentication error
* content:
* application/json:
* schema:
* type: object
* required:
* - code
* - message
* properties:
* code:
* type: string
* description: Error code
* example: E_UNAUTHORIZED
* message:
* type: string
* enum:
* - Invalid code or nonce
* - Invalid userinfo configuration
* description: Specific error message
* example: Invalid code or nonce
* 403:
* description: Authentication restriction
* content:
* application/json:
* schema:
* type: object
* required:
* - code
* - message
* properties:
* code:
* type: string
* description: Error code
* example: E_FORBIDDEN
* message:
* type: string
* enum:
* - Terms acceptance required
* - Admin login required to initialize instance
* description: Specific error message
* example: Terms acceptance required
* 409:
* description: Conflict error
* content:
* application/json:
* schema:
* type: object
* required:
* - code
* - message
* properties:
* code:
* type: string
* description: Error code
* example: E_CONFLICT
* message:
* type: string
* enum:
* - Email already in use
* - Username already in use
* - Active users limit reached
* description: Specific error message
* example: Email already in use
* 422:
* description: Missing required values
* content:
* application/json:
* schema:
* type: object
* required:
* - code
* - message
* properties:
* code:
* type: string
* description: Error code
* example: E_UNPROCESSABLE_ENTITY
* message:
* type: string
* description: Error message
* example: Unable to retrieve required values (email, name)
* 500:
* description: OIDC configuration error
* content:
* application/json:
* schema:
* type: object
* required:
* - code
* - message
* properties:
* code:
* type: string
* description: Error code
* example: E_INTERNAL_SERVER_ERROR
* message:
* type: string
* description: Error message
* example: Invalid OIDC configuration
* security: []
*/
const { getRemoteAddress } = require('../../../utils/remote-address');
const Errors = {
INVALID_OIDC_CONFIGURATION: {
invalidOidcConfiguration: 'Invalid OIDC configuration',
},
INVALID_CODE_OR_NONCE: {
invalidCodeOrNonce: 'Invalid code or nonce',
},
INVALID_USERINFO_CONFIGURATION: {
invalidUserinfoConfiguration: 'Invalid userinfo configuration',
},
TERMS_ACCEPTANCE_REQUIRED: {
termsAcceptanceRequired: 'Terms acceptance required',
},
EMAIL_ALREADY_IN_USE: {
emailAlreadyInUse: 'Email already in use',
},
USERNAME_ALREADY_IN_USE: {
usernameAlreadyInUse: 'Username already in use',
},
ACTIVE_USERS_LIMIT_REACHED: {
activeUsersLimitReached: 'Active users limit reached',
},
MISSING_VALUES: {
missingValues: 'Unable to retrieve required values (email, name)',
},
};
module.exports = {
inputs: {
code: {
type: 'string',
maxLength: 2048,
required: true,
},
nonce: {
type: 'string',
maxLength: 1024,
required: true,
},
withHttpOnlyToken: {
type: 'boolean',
},
},
exits: {
invalidOidcConfiguration: {
responseType: 'serverError',
},
invalidCodeOrNonce: {
responseType: 'unauthorized',
},
invalidUserinfoConfiguration: {
responseType: 'unauthorized',
},
termsAcceptanceRequired: {
responseType: 'forbidden',
},
adminLoginRequiredToInitializeInstance: {
responseType: 'forbidden',
},
emailAlreadyInUse: {
responseType: 'conflict',
},
usernameAlreadyInUse: {
responseType: 'conflict',
},
activeUsersLimitReached: {
responseType: 'conflict',
},
missingValues: {
responseType: 'unprocessableEntity',
},
},
async fn(inputs) {
const remoteAddress = getRemoteAddress(this.req);
const user = await sails.helpers.users
.getOrCreateOneWithOidc(inputs.code, inputs.nonce)
.intercept('invalidOidcConfiguration', () => Errors.INVALID_OIDC_CONFIGURATION)
.intercept('invalidCodeOrNonce', () => {
sails.log.warn(`Invalid code or nonce! (IP: ${remoteAddress})`);
return Errors.INVALID_CODE_OR_NONCE;
})
.intercept('invalidUserinfoConfiguration', () => Errors.INVALID_USERINFO_CONFIGURATION)
.intercept('emailAlreadyInUse', () => Errors.EMAIL_ALREADY_IN_USE)
.intercept('usernameAlreadyInUse', () => Errors.USERNAME_ALREADY_IN_USE)
.intercept('activeLimitReached', () => Errors.ACTIVE_USERS_LIMIT_REACHED)
.intercept('missingValues', () => Errors.MISSING_VALUES);
return sails.helpers.accessTokens.handleSteps
.with({
user,
remoteAddress,
request: this.req,
response: this.res,
withHttpOnlyToken: inputs.withHttpOnlyToken,
})
.intercept('adminLoginRequiredToInitializeInstance', (error) => ({
adminLoginRequiredToInitializeInstance: error.raw,
}))
.intercept('termsAcceptanceRequired', (error) => ({
termsAcceptanceRequired: error.raw,
}));
},
};
+1 -27
View File
@@ -20,33 +20,8 @@
* schema:
* type: object
* required:
* - oidc
* - version
* properties:
* oidc:
* type: object
* required:
* - authorizationUrl
* - endSessionUrl
* - isEnforced
* nullable: true
* description: OpenID Connect configuration (null if not configured)
* properties:
* authorizationUrl:
* type: string
* format: uri
* description: OIDC authorization URL for initiating authentication
* example: https://oidc.example.com/auth
* endSessionUrl:
* type: string
* format: uri
* nullable: true
* description: OIDC end session URL for logout (null if not supported by provider)
* example: https://oidc.example.com/logout
* isEnforced:
* type: boolean
* description: Whether OIDC authentication is enforced (users must use OIDC to login)
* example: false
* activeUsersLimit:
* type: number
* nullable: true
@@ -75,10 +50,9 @@ module.exports = {
const { currentUser } = this.req;
const internalConfig = await InternalConfig.qm.getOneMain();
const oidc = await sails.hooks.oidc.getBootstrap();
return {
item: sails.helpers.bootstrap.presentOne(internalConfig, oidc, currentUser),
item: sails.helpers.bootstrap.presentOne(internalConfig, currentUser),
};
},
};
-4
View File
@@ -200,10 +200,6 @@ module.exports = {
async fn(inputs) {
const { currentUser } = this.req;
if (sails.config.custom.oidcEnforced) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
const values = _.pick(inputs, [
'email',
'password',
+1 -5
View File
@@ -134,11 +134,7 @@ module.exports = {
throw Errors.USER_NOT_FOUND;
}
if (
user.email === sails.config.custom.defaultAdminEmail ||
user.isSsoUser ||
sails.config.custom.demoMode
) {
if (user.email === sails.config.custom.defaultAdminEmail || sails.config.custom.demoMode) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
@@ -136,11 +136,7 @@ module.exports = {
throw Errors.USER_NOT_FOUND;
}
if (
user.email === sails.config.custom.defaultAdminEmail ||
user.isSsoUser ||
sails.config.custom.demoMode
) {
if (user.email === sails.config.custom.defaultAdminEmail || sails.config.custom.demoMode) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
@@ -8,7 +8,7 @@
* /users/{id}/username:
* patch:
* summary: Update user username
* description: Updates a user's username. Users must provide a current password when updating their own username (unless they are SSO users with `oidcIgnoreUsername` enabled). Admins can update any user's username without the current password.
* description: Updates a user's username. Users must provide a current password when updating their own username. Admins can update any user's username without the current password.
* tags:
* - Users
* operationId: updateUserUsername
@@ -136,11 +136,7 @@ module.exports = {
throw Errors.NOT_ENOUGH_RIGHTS;
}
if (user.isSsoUser) {
if (!sails.config.custom.oidcIgnoreUsername) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
} else if (inputs.id === currentUser.id) {
if (inputs.id === currentUser.id) {
if (!inputs.currentPassword) {
throw Errors.INVALID_CURRENT_PASSWORD;
}
+1 -19
View File
@@ -95,10 +95,6 @@
* enum: [byDefault, alphabetically, byCreationTime]
* description: Default sort order for projects display
* example: byDefault
* isSsoUser:
* type: boolean
* description: Whether the user is SSO user (only false value to unlink SSO, for admins)
* example: false
* isDeactivated:
* type: boolean
* description: Whether the user account is deactivated and cannot log in (for admins)
@@ -127,7 +123,6 @@
* $ref: '#/components/responses/Conflict'
*/
const { is } = require('../../../utils/validators');
const { idInput } = require('../../../utils/inputs');
const Errors = {
@@ -205,10 +200,6 @@ module.exports = {
type: 'string',
isIn: Object.values(User.ProjectOrders),
},
isSsoUser: {
type: 'boolean',
custom: is(false),
},
isDeactivated: {
type: 'boolean',
},
@@ -233,7 +224,7 @@ module.exports = {
if (inputs.id === currentUser.id) {
availableInputKeys.push(...User.PERSONAL_FIELD_NAMES);
} else if (currentUser.role === User.Roles.ADMIN) {
availableInputKeys.push('role', 'isSsoUser', 'isDeactivated');
availableInputKeys.push('role', 'isDeactivated');
} else {
throw Errors.USER_NOT_FOUND; // Forbidden
}
@@ -257,14 +248,6 @@ module.exports = {
if (inputs.role || inputs.name) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
} else if (user.isSsoUser) {
if (!sails.config.custom.oidcIgnoreRoles && inputs.role) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
if (inputs.name) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
}
const values = {
@@ -283,7 +266,6 @@ module.exports = {
'defaultEditorMode',
'defaultHomeView',
'defaultProjectsOrder',
'isSsoUser',
'isDeactivated',
]),
};
@@ -11,9 +11,6 @@ module.exports = {
type: 'ref',
required: true,
},
oidc: {
type: 'ref',
},
user: {
type: 'ref',
},
@@ -21,7 +18,6 @@ module.exports = {
fn(inputs) {
const data = {
oidc: inputs.oidc,
termsLanguages: sails.hooks.terms.getLanguages(),
version: sails.config.custom.version,
};
@@ -21,10 +21,6 @@ module.exports = {
userIdOrIds = sails.helpers.utils.mapRecords(inputs.recordOrRecords);
}
await IdentityProviderUser.qm.delete({
userId: userIdOrIds,
});
await Session.qm.delete({
userId: userIdOrIds,
});
@@ -1,200 +0,0 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
code: {
type: 'string',
required: true,
},
nonce: {
type: 'string',
required: true,
},
},
exits: {
invalidOidcConfiguration: {},
invalidCodeOrNonce: {},
invalidUserinfoConfiguration: {},
missingValues: {},
emailAlreadyInUse: {},
usernameAlreadyInUse: {},
activeLimitReached: {},
},
async fn(inputs) {
const client = await sails.hooks.oidc.getClient();
if (!client) {
throw 'invalidOidcConfiguration';
}
let tokenSet;
try {
if (sails.config.custom.oidcUseOauthCallback) {
tokenSet = await client.oauthCallback(
sails.config.custom.oidcRedirectUri,
{
iss: sails.config.custom.oidcIssuer,
code: inputs.code,
},
{
nonce: inputs.nonce,
},
);
} else {
tokenSet = await client.callback(
sails.config.custom.oidcRedirectUri,
{
iss: sails.config.custom.oidcIssuer,
code: inputs.code,
},
{
nonce: inputs.nonce,
},
);
}
} catch (error) {
sails.log.warn(`Error while exchanging OIDC code: ${error}`);
throw 'invalidCodeOrNonce';
}
let claims;
if (sails.config.custom.oidcClaimsSource === 'id_token') {
claims = tokenSet.claims();
} else {
try {
claims = await client.userinfo(tokenSet);
} catch (error) {
let errorText;
if (
error instanceof SyntaxError &&
error.message.includes('Unexpected token e in JSON at position 0')
) {
errorText = 'response is signed';
} else {
errorText = error.toString();
}
sails.log.warn(`Error while fetching OIDC userinfo: ${errorText}`);
throw 'invalidUserinfoConfiguration';
}
}
const email = _.get(claims, sails.config.custom.oidcEmailAttribute);
const name = _.get(claims, sails.config.custom.oidcNameAttribute);
if (!email || !name) {
throw 'missingValues';
}
let role = User.Roles.BOARD_USER;
if (!sails.config.custom.oidcIgnoreRoles) {
const claimsRoles = _.get(claims, sails.config.custom.oidcRolesAttribute);
if (Array.isArray(claimsRoles)) {
// Use a Set here to avoid quadratic time complexity
const claimsRolesSet = new Set(claimsRoles);
const foundRole = [User.Roles.ADMIN, User.Roles.PROJECT_OWNER, User.Roles.BOARD_USER].find(
(roleItem) => {
const configRoles = sails.config.custom[`oidc${_.upperFirst(roleItem)}Roles`];
if (configRoles.includes('*')) {
return true;
}
return configRoles.some((configRole) => claimsRolesSet.has(configRole));
},
);
if (foundRole) {
role = foundRole;
}
}
}
const values = {
email,
role,
name,
isSsoUser: true,
};
if (!sails.config.custom.oidcIgnoreUsername) {
values.username = _.get(claims, sails.config.custom.oidcUsernameAttribute);
}
// This whole block technically needs to be executed in a transaction
// with SERIALIZABLE isolation level (but Waterline does not support
// that), so this will result in errors if for example users are deleted
// concurrently with logging in via OIDC.
let identityProviderUser = await IdentityProviderUser.qm.getOneByIssuerAndSub(
sails.config.custom.oidcIssuer,
claims.sub,
);
let user;
let isCreated = false;
if (identityProviderUser) {
user = await User.qm.getOneById(identityProviderUser.userId);
} else {
// If no IDP/User mapping exists, search for the user by email.
user = await User.qm.getOneByEmail(values.email);
// Otherwise, create a new user.
if (!user) {
user = await sails.helpers.users.createOne
.with({
values,
actorUser: User.OIDC,
})
.intercept('usernameAlreadyInUse', 'usernameAlreadyInUse')
.intercept('activeLimitReached', 'activeLimitReached');
isCreated = true;
}
identityProviderUser = await IdentityProviderUser.qm.createOne({
userId: user.id,
issuer: sails.config.custom.oidcIssuer,
sub: claims.sub || `${user.id}@${sails.config.custom.oidcIssuer}`,
});
}
if (!isCreated) {
values.isDeactivated = false;
const updateFieldKeys = ['email', 'name', 'isSsoUser', 'isDeactivated'];
if (!sails.config.custom.oidcIgnoreUsername) {
updateFieldKeys.push('username');
}
if (!sails.config.custom.oidcIgnoreRoles) {
updateFieldKeys.push('role');
}
const updateValues = {};
// eslint-disable-next-line no-restricted-syntax
for (const k of updateFieldKeys) {
if (values[k] !== user[k]) updateValues[k] = values[k];
}
if (Object.keys(updateValues).length > 0) {
user = await sails.helpers.users.updateOne
.with({
record: user,
values: updateValues,
actorUser: User.OIDC,
})
.intercept('emailAlreadyInUse', 'emailAlreadyInUse')
.intercept('usernameAlreadyInUse', 'usernameAlreadyInUse')
.intercept('activeLimitReached', 'activeLimitReached');
}
}
return user;
},
};
+2 -17
View File
@@ -52,23 +52,8 @@ module.exports = {
const lockedFieldNames = [];
if (sails.config.custom.demoMode) {
lockedFieldNames.push('email', 'password', 'role', 'name', 'username');
} else if (isDefaultAdmin || inputs.record.isSsoUser) {
lockedFieldNames.push('email', 'password', 'name');
if (isDefaultAdmin) {
lockedFieldNames.push('role', 'username');
} else if (inputs.record.isSsoUser) {
if (!sails.config.custom.oidcIgnoreRoles) {
lockedFieldNames.push('role');
}
if (!sails.config.custom.oidcIgnoreUsername) {
lockedFieldNames.push('username');
}
}
}
if (sails.config.custom.oidcEnforced) {
lockedFieldNames.push('isSsoUser');
} else if (isDefaultAdmin) {
lockedFieldNames.push('email', 'password', 'name', 'role', 'username');
}
Object.assign(data, {
-114
View File
@@ -1,114 +0,0 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
/**
* oidc hook
*
* @description :: A hook definition. Extends Sails by adding shadow routes, implicit actions,
* and/or initialization logic.
* @docs :: https://sailsjs.com/docs/concepts/extending-sails/hooks
*/
const openidClient = require('openid-client');
module.exports = function defineOidcHook(sails) {
let client = null;
let clientInitPromise = null;
return {
/**
* Runs when this Sails app loads/lifts.
*/
async initialize() {
if (!this.isEnabled()) {
return;
}
sails.log.info('Initializing custom hook (`oidc`)');
},
async getClient() {
if (!this.isEnabled()) {
return null;
}
if (client) {
return client;
}
if (clientInitPromise) {
return clientInitPromise;
}
clientInitPromise = (async () => {
sails.log.info('Initializing OIDC client');
if (sails.config.custom.oidcTimeout !== null) {
openidClient.custom.setHttpOptionsDefaults({
timeout: sails.config.custom.oidcTimeout,
});
}
let issuer;
try {
issuer = await openidClient.Issuer.discover(sails.config.custom.oidcIssuer);
} catch (error) {
sails.log.warn(`Error while initializing OIDC client: ${error}`);
clientInitPromise = null;
return null;
}
const metadata = {
client_id: sails.config.custom.oidcClientId,
client_secret: sails.config.custom.oidcClientSecret,
redirect_uris: [sails.config.custom.oidcRedirectUri],
response_types: ['code'],
userinfo_signed_response_alg: sails.config.custom.oidcUserinfoSignedResponseAlg,
};
if (sails.config.custom.oidcIdTokenSignedResponseAlg) {
metadata.id_token_signed_response_alg = sails.config.custom.oidcIdTokenSignedResponseAlg;
}
client = new issuer.Client(metadata);
return client;
})();
return clientInitPromise;
},
async getBootstrap() {
const instance = await this.getClient();
if (!instance) {
return null;
}
const authorizationUrlParams = {
scope: sails.config.custom.oidcScopes,
};
if (!sails.config.custom.oidcUseDefaultResponseMode) {
authorizationUrlParams.response_mode = sails.config.custom.oidcResponseMode;
}
const bootstrap = {
authorizationUrl: instance.authorizationUrl(authorizationUrlParams),
endSessionUrl: instance.issuer.end_session_endpoint ? instance.endSessionUrl({}) : null,
isEnforced: sails.config.custom.oidcEnforced,
};
if (sails.config.custom.oidcDebug) {
bootstrap.debug = true;
}
return bootstrap;
},
isEnabled() {
return !!sails.config.custom.oidcIssuer;
},
};
};
@@ -1,23 +0,0 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
/* Query methods */
const createOne = (values) => IdentityProviderUser.create({ ...values }).fetch();
const getOneByIssuerAndSub = (issuer, sub) =>
IdentityProviderUser.findOne({
issuer,
sub,
});
// eslint-disable-next-line no-underscore-dangle
const delete_ = (criteria) => IdentityProviderUser.destroy(criteria).fetch();
module.exports = {
createOne,
getOneByIssuerAndSub,
delete: delete_,
};
-46
View File
@@ -1,46 +0,0 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
/**
* IdentityProviderUser.js
*
* @description :: A model definition represents a database table/collection.
* @docs :: https://sailsjs.com/docs/concepts/models-and-orm/models
*/
module.exports = {
attributes: {
// ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗
// ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
// ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝
issuer: {
type: 'string',
isNotEmptyString: true,
allowNull: true,
},
sub: {
type: 'string',
isNotEmptyString: true,
allowNull: true,
},
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
// ║╣ ║║║╠╩╗║╣ ║║╚═╗
// ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝
// ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗
// ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝
userId: {
model: 'User',
required: true,
columnName: 'user_id',
},
},
tableName: 'identity_provider_user',
};
+1 -17
View File
@@ -142,11 +142,6 @@
* default: byDefault
* description: Default sort order for projects display (personal field)
* example: byDefault
* isSsoUser:
* type: boolean
* default: false
* description: Whether the user is SSO user (private field)
* example: false
* isDeactivated:
* type: boolean
* default: false
@@ -238,7 +233,7 @@ const LANGUAGES = [
];
// TODO: find better way to handle apiKeyHash and apiKeyCreatedAt
const PRIVATE_FIELD_NAMES = ['email', 'apiKeyPrefix', 'apiKeyHash', 'isSsoUser', 'apiKeyCreatedAt'];
const PRIVATE_FIELD_NAMES = ['email', 'apiKeyPrefix', 'apiKeyHash', 'apiKeyCreatedAt'];
const PERSONAL_FIELD_NAMES = [
'language',
@@ -256,11 +251,6 @@ const INTERNAL = {
role: Roles.ADMIN,
};
const OIDC = {
id: '_oidc',
role: Roles.ADMIN,
};
module.exports = {
Roles,
EditorModes,
@@ -270,7 +260,6 @@ module.exports = {
PRIVATE_FIELD_NAMES,
PERSONAL_FIELD_NAMES,
INTERNAL,
OIDC,
attributes: {
// ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗
@@ -378,11 +367,6 @@ module.exports = {
allowNull: true,
columnName: 'terms_signature',
},
isSsoUser: {
type: 'boolean',
defaultsTo: false,
columnName: 'is_sso_user',
},
isDeactivated: {
type: 'boolean',
defaultsTo: false,