Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
approvedGitRepositories:
- "**"

compressionLevel: mixed

enableGlobalCache: false

enableScripts: true

nodeLinker: node-modules

npmMinimalAgeGate: 0
24 changes: 24 additions & 0 deletions app/controllers/src/auth/auth-providers.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {Controller, Get} from "@nestjs/common"
import {ConfigProvider} from "@external/config"
import {PublicRoute} from "../../../main/src/auth/jwt.authguard"

@Controller("auth/providers")
export class AuthProvidersController {
constructor(private readonly configProvider: ConfigProvider) {}

@PublicRoute()
@Get()
getProviders(): Array<{id: string; displayName: string}> {
const providers: Array<{id: string; displayName: string}> = []

for (const [id, _config] of this.configProvider.oidcProviders.entries())
// In the real system, you might get a displayName from the IDP directly or from config,
// here we just return the ID capitalized as a default if displayName doesn't exist
providers.push({
id,
displayName: id.charAt(0).toUpperCase() + id.slice(1)
})

return providers
}
}
8 changes: 8 additions & 0 deletions app/controllers/src/auth/auth.mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export function generateErrorResponseForRefreshUserToken(error: RefreshUserToken
case "auth_token_generation_failed":
case "auth_authorization_url_generation_failed":
case "auth_missing_email_from_oidc_provider":
case "auth_identity_conflict":
case "user_identity_already_exists":
case "user_not_found":
case "request_invalid_user_identifier":
case "auth_invalid_redirect_uri":
Expand Down Expand Up @@ -218,6 +220,8 @@ export function generateErrorResponseForExchangePrivilegeToken(
case "auth_token_generation_failed":
case "unknown_error":
case "auth_missing_email_from_oidc_provider":
case "auth_identity_conflict":
case "user_identity_already_exists":
case "auth_authorization_url_generation_failed":
case "pkce_code_generation_failed":
case "pkce_code_storage_failed":
Expand Down Expand Up @@ -324,6 +328,8 @@ export function generateErrorResponseForRefreshAgentToken(
case "auth_token_generation_failed":
case "auth_authorization_url_generation_failed":
case "auth_missing_email_from_oidc_provider":
case "auth_identity_conflict":
case "user_identity_already_exists":
case "user_not_found":
case "request_invalid_user_identifier":
case "request_invalid_dpop_jkt":
Expand Down Expand Up @@ -534,6 +540,8 @@ export function generateErrorResponseForGenerateToken(error: GenerateTokenError,
case "auth_token_generation_failed":
case "auth_authorization_url_generation_failed":
case "auth_missing_email_from_oidc_provider":
case "auth_identity_conflict":
case "user_identity_already_exists":
case "oidc_unknown_error":
case "unknown_error":
case "auth_invalid_redirect_uri":
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/src/auth/cli-auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export class CliAuthController {
async initiateCliLogin(@Body() body: unknown): Promise<{authorizationUrl: string}> {
const result = await pipe(
TE.fromEither(validateInitiateCliLoginRequest(body)),
TE.chainW(({redirectUri}) => this.authService.initiateOidcLoginFromCli(redirectUri)),
TE.chainW(({redirectUri, provider}) => this.authService.initiateOidcLoginFromCli(redirectUri, provider)),
logSuccess("CLI OIDC login initiated", "CliAuthController")
)()

Expand Down
8 changes: 8 additions & 0 deletions app/controllers/src/auth/cli-auth.mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ export function generateErrorResponseForCliInitiate(error: CliAuthError, context
case "auth_token_generation_failed":
case "auth_authorization_url_generation_failed":
case "auth_missing_email_from_oidc_provider":
case "auth_identity_conflict":
case "user_identity_already_exists":
case "user_not_found":
case "request_invalid_user_identifier":
case "auth_invalid_redirect_uri":
Expand Down Expand Up @@ -238,6 +240,8 @@ export function generateErrorResponseForCliGenerateToken(error: CliAuthError, co
case "auth_token_generation_failed":
case "auth_authorization_url_generation_failed":
case "auth_missing_email_from_oidc_provider":
case "auth_identity_conflict":
case "user_identity_already_exists":
case "user_not_found":
case "request_invalid_user_identifier":
case "auth_invalid_redirect_uri":
Expand Down Expand Up @@ -421,6 +425,8 @@ export function generateErrorResponseForCliRefreshUserToken(error: CliAuthError,
case "auth_token_generation_failed":
case "auth_authorization_url_generation_failed":
case "auth_missing_email_from_oidc_provider":
case "auth_identity_conflict":
case "user_identity_already_exists":
case "user_not_found":
case "request_invalid_user_identifier":
case "auth_invalid_redirect_uri":
Expand Down Expand Up @@ -604,6 +610,8 @@ export function generateErrorResponseForCliExchangePrivilegeToken(error: CliAuth
case "auth_token_generation_failed":
case "auth_authorization_url_generation_failed":
case "auth_missing_email_from_oidc_provider":
case "auth_identity_conflict":
case "user_identity_already_exists":
case "user_not_found":
case "request_invalid_user_identifier":
case "auth_invalid_redirect_uri":
Expand Down
6 changes: 4 additions & 2 deletions app/controllers/src/auth/cli-auth.validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ export type CliPrivilegedTokenExchangeRequestValidationError =

export function validateInitiateCliLoginRequest(
body: unknown
): Either<CliInitiateLoginRequestValidationError, {redirectUri: string}> {
): Either<CliInitiateLoginRequestValidationError, {redirectUri: string; provider?: string}> {
if (!body || !hasOwnProperty(body, "redirectUri")) return left("request_missing_redirect_uri")
if (typeof body.redirectUri !== "string" || !body.redirectUri) return left("request_invalid_redirect_uri")

return right({redirectUri: body.redirectUri})
const provider = hasOwnProperty(body, "provider") && typeof body.provider === "string" ? body.provider : undefined

return right({redirectUri: body.redirectUri, provider})
}

export function validateGenerateCliTokenRequest(
Expand Down
1 change: 1 addition & 0 deletions app/controllers/src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./auth.controller"
export * from "./web-auth.controller"
export * from "./cli-auth.controller"
export * from "./auth-providers.controller"
4 changes: 2 additions & 2 deletions app/controllers/src/auth/web-auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ export class WebAuthController {
@PublicRoute()
@HttpCode(HttpStatusCode.Found)
@Get("login")
async login(@Res() res: Response): Promise<void> {
async login(@Query("provider") provider: string | undefined, @Res() res: Response): Promise<void> {
const result = await pipe(
this.authService.initiateOidcLogin(),
this.authService.initiateOidcLogin(provider),
logSuccess("OIDC login initiated", "WebAuthController")
)()

Expand Down
3 changes: 2 additions & 1 deletion app/controllers/src/controllers.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {AuthModule} from "@app/auth"
import {WorkflowsController} from "./workflows"
import {WorkflowTemplatesController} from "./workflow-templates"
import {WorkflowTemplateInternalController} from "./internal"
import {AuthController, WebAuthController, CliAuthController} from "./auth"
import {AuthController, WebAuthController, CliAuthController, AuthProvidersController} from "./auth"
import {RolesController} from "./roles"
import {HealthController} from "./internal/health"
import {PingController} from "./ping"
Expand All @@ -34,6 +34,7 @@ const internalControllers = [WorkflowTemplateInternalController, HealthControlle
AuthController,
WebAuthController,
CliAuthController,
AuthProvidersController,
RolesController,
PingController,
QuotasController,
Expand Down
94 changes: 59 additions & 35 deletions app/external/src/config/config-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export class ConfigProvider implements ConfigProviderInterface {
readonly isPrivilegeMode: boolean
readonly dbConnectionUrl: string
readonly emailProviderConfig: Option<EmailProviderConfig>
readonly oidcConfig: OidcProviderConfig
readonly oidcProviders: Map<string, OidcProviderConfig>
readonly jwtConfig: JwtConfig
readonly redisConfig: RedisConfig
readonly rateLimitConfig: RateLimitConfig
Expand All @@ -56,7 +56,7 @@ export class ConfigProvider implements ConfigProviderInterface {
this.isPrivilegeMode = this.validatePrivilegeMode()
this.dbConnectionUrl = this.validateConnectionUrl()
this.emailProviderConfig = ConfigProvider.validateEmailProviderConfig()
this.oidcConfig = this.validateOidcProviderConfig()
this.oidcProviders = this.validateOidcProviderConfig()
this.jwtConfig = this.validateJwtConfig()
// redisConfig must be initialized BEFORE rateLimitConfig because the latter
// falls back to the main redisConfig if no specific rate limit connection is provided.
Expand Down Expand Up @@ -203,43 +203,66 @@ export class ConfigProvider implements ConfigProviderInterface {
return smtpAllowSelfSignedRaw.toLowerCase() === "true"
}

private validateOidcProviderConfig(): OidcProviderConfig {
const providerRaw = process.env.OIDC_PROVIDER
const provider = this.parseOidcProvider(providerRaw)
private validateOidcProviderConfig(): Map<string, OidcProviderConfig> {
const providers = new Map<string, OidcProviderConfig>()
const prefixMap = new Map<string, Record<string, string | undefined>>()

const issuerUrl = process.env.OIDC_ISSUER_URL
const clientId = process.env.OIDC_CLIENT_ID
const clientSecret = process.env.OIDC_CLIENT_SECRET
const redirectUri = process.env.OIDC_REDIRECT_URI
const scopes = process.env.OIDC_SCOPES
// Scan for OIDC_PROVIDER_<NAME>_<PROPERTY>
for (const [key, value] of Object.entries(process.env)) {
const match = /^OIDC_PROVIDER_([A-Za-z0-9_-]+)_(.+)$/.exec(key)
if (match) {
const id = match[1]!
const property = match[2]!
if (!prefixMap.has(id)) prefixMap.set(id, {})

if (!issuerUrl || !clientId || !clientSecret || !redirectUri)
throw new Error("Incomplete OIDC provider configuration")
prefixMap.get(id)![property] = value
}
}

if (issuerUrl.length === 0 || clientId.length === 0 || clientSecret.length === 0 || redirectUri.length === 0)
throw new Error("OIDC provider configuration values cannot be empty")
if (prefixMap.size === 0)
throw new Error("No OIDC providers configured. At least one OIDC_PROVIDER_<NAME>_<PROPERTY> is required.")

this.validateUrl(issuerUrl, "OIDC_ISSUER_URL")
this.validateUrl(redirectUri, "OIDC_REDIRECT_URI")
for (const [id, env] of prefixMap.entries()) {
const providerRaw = env.TYPE
const provider = this.parseOidcProvider(providerRaw)

const override = this.parseOidcEndpoints(
process.env.OIDC_AUTHORIZATION_ENDPOINT,
process.env.OIDC_TOKEN_ENDPOINT,
process.env.OIDC_USERINFO_ENDPOINT
)
const issuerUrl = env.ISSUER_URL
const clientId = env.CLIENT_ID
const clientSecret = env.CLIENT_SECRET
const redirectUri = env.REDIRECT_URI
const scopes = env.SCOPES

const allowInsecure = this.parseOidcAllowInsecure(process.env.OIDC_ALLOW_INSECURE)
if (!issuerUrl || !clientId || !clientSecret || !redirectUri)
throw new Error(`Incomplete OIDC provider configuration for '${id}'`)

return {
provider,
issuerUrl,
clientId,
clientSecret,
redirectUri,
allowInsecure,
override,
scopes
if (issuerUrl.length === 0 || clientId.length === 0 || clientSecret.length === 0 || redirectUri.length === 0)
throw new Error(`OIDC provider configuration values cannot be empty for '${id}'`)

this.validateUrl(issuerUrl, `OIDC_PROVIDER_${id}_ISSUER_URL`)
this.validateUrl(redirectUri, `OIDC_PROVIDER_${id}_REDIRECT_URI`)

const override = this.parseOidcEndpoints(
env.AUTHORIZATION_ENDPOINT,
env.TOKEN_ENDPOINT,
env.USERINFO_ENDPOINT,
id
)

const allowInsecure = this.parseOidcAllowInsecure(env.ALLOW_INSECURE)

providers.set(id, {
provider,
issuerUrl,
clientId,
clientSecret,
redirectUri,
allowInsecure,
override,
scopes
})
}

return providers
}

private parseOidcProvider(providerRaw: string | undefined): OidcProviderConfig["provider"] {
Expand All @@ -259,13 +282,14 @@ export class ConfigProvider implements ConfigProviderInterface {
private parseOidcEndpoints(
authEndpoint?: string,
tokenEndpoint?: string,
userinfoEndpoint?: string
userinfoEndpoint?: string,
id?: string
): OidcProviderConfig["override"] | undefined {
// Either all attributes are provided or none, mix is considered an error.
if (authEndpoint && tokenEndpoint && userinfoEndpoint) {
this.validateUrl(authEndpoint, "OIDC_AUTHORIZATION_ENDPOINT")
this.validateUrl(tokenEndpoint, "OIDC_TOKEN_ENDPOINT")
this.validateUrl(userinfoEndpoint, "OIDC_USERINFO_ENDPOINT")
this.validateUrl(authEndpoint, `OIDC_PROVIDER_${id}_AUTHORIZATION_ENDPOINT`)
this.validateUrl(tokenEndpoint, `OIDC_PROVIDER_${id}_TOKEN_ENDPOINT`)
this.validateUrl(userinfoEndpoint, `OIDC_PROVIDER_${id}_USERINFO_ENDPOINT`)

return {
authorizationEndpoint: authEndpoint,
Expand Down
2 changes: 1 addition & 1 deletion app/external/src/config/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export interface ConfigProviderInterface {
isPrivilegeMode: boolean
dbConnectionUrl: string
emailProviderConfig: Option<EmailProviderConfig>
oidcConfig: OidcProviderConfig
oidcProviders: Map<string, OidcProviderConfig>
jwtConfig: JwtConfig
redisConfig: RedisConfig
rateLimitConfig: RateLimitConfig
Expand Down
1 change: 1 addition & 0 deletions app/external/src/database/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ export * from "./refresh-token.repository"
export * from "./health.repository"
export * from "./quota.repository"
export * from "./audit-log.repository"
export * from "./user-identity.repository"
export * from "./transaction-manager"
1 change: 1 addition & 0 deletions app/external/src/database/pkce-session.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export class PkceSessionDbRepository implements PkceSessionRepository {
codeVerifier: decryptedVerifier,
redirectUri: session.redirectUri,
oidcState: session.oidcState,
providerId: session.providerId,
expiresAt: session.expiresAt,
occ: session.occ,
usedAt: session.usedAt || undefined
Expand Down
Loading
Loading