feat: implement Dex user provisioning and password hashing functionality - #1077
feat: implement Dex user provisioning and password hashing functionality#1077CasLubbers wants to merge 7 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
The Dex UpdatePassword request construction risks unintentionally clearing credentials/fields by always sending empty defaults, and should be corrected before merge.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR adds support for provisioning and managing users in Dex (as an alternative to Keycloak), including hashing plaintext passwords for Dex’s password store and mapping Otomi user roles/teams to Dex group strings.
Changes:
- Introduces
AUTH_PROVIDERandDEX_GRPC_ADDRESSenv configuration to switch between Keycloak and Dex provisioning paths. - Adds a Dex gRPC client, group mapping helpers, and bcrypt-based password hashing utilities.
- Updates user CRUD flows, JWT group parsing, and OpenAPI requirements to support Dex-backed users and optional first/last names.
File summaries
| File | Description |
|---|---|
| src/validators.ts | Adds AUTH_PROVIDER and DEX_GRPC_ADDRESS validators. |
| src/utils/userUtils.ts | Adds Dex group derivation + Dex Password→User mapping. |
| src/utils/userUtils.test.ts | Tests for Dex group derivation and mapping helpers. |
| src/utils/passwordUtils.ts | Adds bcrypt hashing helper for provisioning Dex password records. |
| src/utils/passwordUtils.test.ts | Tests for bcrypt hashing behavior (verify + salted). |
| src/proto/dex/api.proto | Adds Dex admin API proto for TS client generation. |
| src/clients/dexClient.ts | Implements Dex gRPC client wrapper (create/update/list/delete password records). |
| src/clients/dexClient.test.ts | Unit tests for the Dex client wrapper behavior. |
| src/clients/dexClient.integration.test.ts | Optional integration test for real Dex gRPC endpoint. |
| src/otomi-stack.ts | Adds Dex-backed implementations for user CRUD + team membership edits. |
| src/otomi-stack.test.ts | Adds tests covering Dex provisioning and Dex-mode user operations. |
| src/openapi/user.yaml | Makes firstName/lastName optional in the User schema. |
| src/middleware/jwt.ts | Ignores Dex no-groups sentinel when mapping JWT groups→roles/teams. |
| src/middleware/jwt.test.ts | Adds coverage for sentinel behavior in JWT claim mapping. |
| package.json | Adds dependencies and build/postinstall steps to generate Dex client code. |
| package-lock.json | Locks new deps for Dex gRPC client generation and bcrypt hashing. |
| eslint.config.mjs | Excludes src/generated/* from linting. |
| .gitignore | Ignores generated Dex client output under src/generated/. |
Review details
Suppressed comments (1)
src/otomi-stack.ts:1547
- When userData.id is missing, the error message currently becomes "User undefined not found", which is misleading and makes debugging harder. Return an explicit “id is required” message for this validation failure.
if (!userData.id) {
throw new NotExistError(`User ${userData.id} not found`)
}
- Files reviewed: 16/18 changed files
- Comments generated: 6
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
🟡 Changes recommended
package.json currently includes a duplicate JSON key (breaking/ambiguous metadata) and there are remaining production-readiness concerns around pulling gRPC client deps into middleware plus plaintext gRPC credentials.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (6)
Previously missed (1) — in code that hasn't changed since the last review.
src/utils/userUtils.test.ts:2
- This test hard-codes the Dex "no groups" sentinel string. Importing and using DEX_NO_GROUPS_SENTINEL avoids drift if the sentinel value ever changes.
This issue also appears on line 70 of the same file.
package.json:128
- package.json contains a duplicate "name" property, which makes the JSON ambiguous and can confuse tooling (only the last key wins). Remove the duplicate entry.
"name": "@redkubes/otomi-api",
"name": "@redkubes/otomi-api",
package.json:152
- The test scripts rely on src/generated/dex being present (generated by postinstall), but running
git clean -fdxor deleting ignored files can remove it andnpm testwon't regenerate it. Consider generating the Dex client as part of the test scripts too, similar to the build script.
"postinstall": "npm run build:models && npm run gen:dex-client",
src/utils/userUtils.test.ts:72
- Use the shared DEX_NO_GROUPS_SENTINEL constant instead of the literal 'no_groups' so the test stays consistent with production behavior.
it('strips the no-groups sentinel and treats it as no groups at all', () => {
expect(dexPasswordToUser(password({ groups: ['__no_groups__'] }))).toMatchObject({
isPlatformAdmin: false,
src/middleware/jwt.ts:4
- jwt middleware imports DEX_NO_GROUPS_SENTINEL from src/clients/dexClient, which also pulls in
@grpc/grpc-jsand env parsing on every server startup even when AUTH_PROVIDER=keycloak. Consider moving the sentinel constant into a lightweight constants module (imported by both dexClient and jwt) to avoid unnecessary dependencies in the hot-path middleware.
import { DEX_NO_GROUPS_SENTINEL } from 'src/clients/dexClient'
src/clients/dexClient.ts:42
- DexClient is created with ChannelCredentials.createInsecure(), which sends credentials and password hashes over plaintext. If this will be used outside strictly local/dev networks, please wire TLS (e.g., createSsl() with configurable CA/hostname) or gate insecure mode behind an explicit environment flag.
// TODO(#3536): createInsecure() is a known temporary gap pending TLS wiring in apl-core.
client = new DexClient(env.DEX_GRPC_ADDRESS, ChannelCredentials.createInsecure())
}
- Files reviewed: 17/19 changed files
- Comments generated: 1
- Review effort level: Lite
There was a problem hiding this comment.
🔵 Needs a closer look
There are correctness/operational blockers in the current diff (e.g., Dex-mode editUser can return an email change that cannot actually be persisted, and package.json has a duplicate key) that should be resolved before approval.
Review details
Suppressed comments (4)
Previously missed (1) — in code that hasn't changed since the last review.
src/otomi-stack.ts:1395
- In Dex mode, editUser can accept an updated
emailbut the Dex UpdatePassword API uses the existing email as an immutable lookup key, so the change will not persist. The method currently returns a User object with the new email anyway, which makes the API response inconsistent with subsequent reads. Also, password updates in Dex mode should enforce the same minimum length as createUser to avoid accepting weak passwords.
package.json:129
- package.json contains the "name" property twice; duplicate JSON keys can lead to unpredictable behavior across tooling (some parsers reject duplicates). Remove the duplicate entry so the package metadata is unambiguous.
"main": "dist/src/app.js",
"name": "@redkubes/otomi-api",
"name": "@redkubes/otomi-api",
"publishConfig": {
src/clients/dexClient.ts:41
- Dex gRPC is currently instantiated with ChannelCredentials.createInsecure(), which means traffic (including password hashes and group membership updates) is sent without transport security. If Dex is reachable beyond a strictly trusted network boundary, this can enable interception or tampering. Consider wiring TLS/mTLS (or explicitly limiting the address to a local/cluster-internal endpoint) before enabling AUTH_PROVIDER=dex in production.
throw new DexProvisionError('DEX_GRPC_ADDRESS must be set when AUTH_PROVIDER=dex')
}
if (!client) {
// TODO(#3536): createInsecure() is a known temporary gap pending TLS wiring in apl-core.
client = new DexClient(env.DEX_GRPC_ADDRESS, ChannelCredentials.createInsecure())
package.json:153
- The Dex client types are generated into src/generated/dex, and postinstall/build now run gen:dex-client, but the test scripts still only run build:models. If installs are performed with --ignore-scripts (skipping postinstall) or src/generated is cleaned,
npm test/npm run test:patternwill fail to compile. Consider running gen:dex-client as part of the test scripts too.
"lint-staged": "lint-staged",
"postinstall": "npm run build:models && npm run gen:dex-client",
"pre-release:client": "npm version prerelease --preid rc --no-commit-hooks --no-git-tag-version && bin/release-client.sh",
- Files reviewed: 17/19 changed files
- Comments generated: 0 new
- Review effort level: Lite
There was a problem hiding this comment.
🔵 Needs a closer look
It introduces a new authentication/user-provisioning backend (Dex) plus password-handling and gRPC integration that should receive final human verification.
Review details
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
src/proto/dex/api.proto:243
- Typo in comment: "disovery" should be "discovery".
src/otomi-stack.ts:1524
- This comment suggests the Dex team update batch is all-or-nothing ("each update either lands in Dex or the whole request rejects"), but the implementation applies updates sequentially and can leave partial updates persisted if a later call fails (see the for-loop below and the corresponding test case that expects two calls then a rejection). Please update the comment (or implement rollback/transaction semantics) to reflect the actual behavior.
// Dex has no Git counterpart to keep in sync, so there's no two-pass ordering to worry about
// here — each update either lands in Dex or the whole request rejects.
private async editDexTeamUsers(
src/otomi-stack.ts:1544
- When userData.id is missing, this throws
NotExistErrorwith messageUser undefined not found, which is misleading (the input is invalid, not a missing user record). Consider returning a 400 with a clear message that the id is required.
if (!userData.id) {
throw new NotExistError(`User ${userData.id} not found`)
}
- Files reviewed: 17/19 changed files
- Comments generated: 0 new
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
There are a few misleading comments/docs and an unclear error message in newly added Dex-mode code paths that should be corrected to match actual behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
src/otomi-stack.ts:1524
- The comment implies the batch is all-or-nothing, but updates are applied sequentially and a failure partway through can leave earlier users updated in Dex. This is misleading for future maintainers and incident triage.
// Dex has no Git counterpart to keep in sync, so there's no two-pass ordering to worry about
// here — each update either lands in Dex or the whole request rejects.
private async editDexTeamUsers(
src/otomi-stack.ts:1544
- When userData.id is missing, the error message interpolates to "User undefined not found", which is confusing and makes debugging bad requests harder.
if (!userData.id) {
throw new NotExistError(`User ${userData.id} not found`)
}
- Files reviewed: 17/19 changed files
- Comments generated: 2
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
There is a confirmed JWT group-to-team mapping bug (team-admin can be misinterpreted as team admin) plus a couple of test-side issues that should be addressed before merging.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
src/otomi-stack.ts:1544
- When
userData.idis missing, the error message becomesUser undefined not found, which is misleading (this is an invalid request rather than a missing user). Use a clearer message for the missing-id case.
if (!userData.id) {
throw new NotExistError(`User ${userData.id} not found`)
}
- Files reviewed: 17/19 changed files
- Comments generated: 3
- Review effort level: Lite
Signed-off-by: Cas Lubbers <clubbers@akamai.com>
There was a problem hiding this comment.
🟡 Changes recommended
Critical correctness and security issues remain in Dex error handling, metadata persistence, team updates, and bcrypt input handling.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (7)
src/clients/dexClient.integration.test.ts:12
- This fixture is not a complete bcrypt hash: it is 56 characters, whereas the bcrypt encoding stored by Dex is 60 characters. A real Dex instance may reject it or persist a password that can never authenticate, so the integration test should use a known valid bcrypt hash.
passwordHash: '$2a$10$abcdefghijklmnopqrstuuVGm5ZQeXk6b2ZQeXk6b2ZQeXk6b',
src/clients/dexClient.ts:39
callWithRetryretries every thrown error, but the callbacks below intentionally throwDexProvisionErrorfor non-transientalreadyExistsandnotFoundresponses. That produces four identical RPCs for deterministic failures and can make a successful create whose response is lost look like a failed duplicate after retry; restrict retries to transient gRPC status codes so application-level responses are not retried.
function callWithRetry<T>(fn: () => Promise<T>): Promise<T> {
return retry(fn, { retries: 3, minTimeout: 200 })
src/clients/dexClient.ts:120
listDexPasswordsis the read path for deduplication,getUser,getAllUsers, and edits, butdexClient.test.tsnever mocks or callslistPasswords; the current mock client does not define it. Add success and RPC-error tests so regressions in the request/response mapping and retry behavior cannot pass unnoticed.
export async function listDexPasswords(): Promise<Password[]> {
const dex = getDexClient()
return callWithRetry(
() =>
new Promise<Password[]>((resolve, reject) => {
src/openapi/user.yaml:118
editDexUseralso acceptsdata.initialPasswordand hashes it at lines 1406-1410 to reset a Dex password, so saying this may be set only "on create" is inconsistent with the edit behavior introduced here. Update the description to document password resets on edit and that the field is ignored for other providers.
description: The initial password of the user. With Dex as issuer, an admin may set this on create; otherwise one is generated.
src/otomi-stack.test.ts:833
- When
originalAuthProvideris undefined, assigning it back withprocess.env.AUTH_PROVIDER = originalAuthProviderstores the string"undefined"rather than removing the variable in Node. That can leak an invalid provider value into later tests and make subsequentcleanEnvcalls fail; delete the property when the original value was absent.
afterEach(() => {
process.env.AUTH_PROVIDER = originalAuthProvider
jest.clearAllMocks()
src/otomi-stack.ts:1551
editDexTeamUsersdoes not validate the requested team IDs before writing groups, unlikecreateUserandeditDexUser. A platform admin can therefore storeteam-<nonexistent>in Dex;dexPasswordToUserreports that team while JWT processing ignores it as unknown, leaving the API and authorization views inconsistent. ValidateupdatedUserbefore the RPC.
const updatedUser: User = { ...existingUser, teams: userData.teams }
await updateDexPassword({ email: match.email, newGroups: deriveDexGroups(updatedUser) })
src/validators.ts:81
- Because
DEX_GRPC_ADDRESSis optional incleanEnvand this check runs only insidegetDexClient, a deployment withAUTH_PROVIDER=dexbut no address starts successfully and only fails later when a user operation is attempted. Since the validator documents this value as required for Dex, validate the combination during application/stack initialization instead of deferring the configuration error to requests.
export const DEX_GRPC_ADDRESS = str({
desc: 'host:port of the Dex gRPC API. Required when AUTH_PROVIDER=dex.',
example: 'dex-grpc.dex.svc:5557',
devDefault: 'localhost:5557',
default: undefined,
- Files reviewed: 18/20 changed files
- Comments generated: 6
- Review effort level: Lite
| export class DexProvisionError extends Error { | ||
| constructor( | ||
| message: string, | ||
| public readonly cause?: unknown, | ||
| ) { | ||
| super(message) | ||
| this.name = 'DexProvisionError' | ||
| } |
| private assertPasswordLength(password: string): void { | ||
| if (password.length < MIN_USER_PASSWORD_LENGTH) { | ||
| throw new HttpError(400, `Password must be at least ${MIN_USER_PASSWORD_LENGTH} characters.`) |
| await createDexPassword({ | ||
| id: user.id as string, | ||
| email: user.email, | ||
| passwordHash, | ||
| username: user.email.split('@')[0], | ||
| groups: deriveDexGroups(user), | ||
| }) |
| this.assertCanUpdateUserTeams(sessionUser, existingUser, userData.teams as string[]) | ||
|
|
||
| const updatedUser: User = { ...existingUser, teams: userData.teams } | ||
| await updateDexPassword({ email: match.email, newGroups: deriveDexGroups(updatedUser) }) |
| export async function hashPassword(plaintext: string): Promise<string> { | ||
| return bcrypt.hash(plaintext, SALT_ROUNDS) |
| export function dexPasswordToUser(password: Password): User { | ||
| const groups = password.groups.filter((group) => group !== DEX_NO_GROUPS_SENTINEL) | ||
| return { | ||
| id: password.userId, | ||
| email: password.email, |
No description provided.