From 7c30e43ef732f16b468228ef5104e38aa40d3ddd Mon Sep 17 00:00:00 2001 From: Morten Olsen Date: Thu, 16 Oct 2025 15:55:00 +0200 Subject: [PATCH] feat: initial OIDC support --- src/access/access.token.ts | 28 +++++++++------- src/backbone.ts | 16 +++++---- src/config/config.ts | 23 +++++++++++++ src/oidc/oidc.handler.ts | 66 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 17 deletions(-) create mode 100644 src/oidc/oidc.handler.ts diff --git a/src/access/access.token.ts b/src/access/access.token.ts index d4c832d..a60b864 100644 --- a/src/access/access.token.ts +++ b/src/access/access.token.ts @@ -3,10 +3,8 @@ import jwt from 'jsonwebtoken'; import { statementSchema } from './access.schemas.ts'; import type { AccessProvider } from './access.provider.ts'; - -type AccessTokensOptions = { - secret: string | Buffer; -}; +import type { Services } from '#root/utils/services.ts'; +import { Config } from '#root/config/config.ts'; const tokenBodySchema = z.object({ statements: z.array(statementSchema), @@ -15,21 +13,29 @@ const tokenBodySchema = z.object({ type TokenBody = z.infer; class AccessTokens implements AccessProvider { - #options: AccessTokensOptions; + #services: Services; - constructor(options: AccessTokensOptions) { - this.#options = options; + constructor(services: Services) { + this.#services = services; } public generate = (options: TokenBody) => { - const { secret } = this.#options; - const token = jwt.sign(options, secret); + const config = this.#services.get(Config); + const { tokenSecret } = config; + if (!tokenSecret) { + throw new Error('Token secret does not exist'); + } + const token = jwt.sign(options, tokenSecret); return token; }; public getAccess = async (token: string) => { - const { secret } = this.#options; - const data = jwt.verify(token, secret); + const config = this.#services.get(Config); + const { tokenSecret } = config; + if (!tokenSecret) { + throw new Error('Token secret does not exist'); + } + const data = jwt.verify(token, tokenSecret); const parsed = tokenBodySchema.parse(data); return parsed; }; diff --git a/src/backbone.ts b/src/backbone.ts index 473e02e..3a13d9d 100644 --- a/src/backbone.ts +++ b/src/backbone.ts @@ -1,6 +1,8 @@ import { AccessHandler } from './access/access.handler.ts'; +import { AccessTokens } from './access/access.token.ts'; import { Config } from './config/config.ts'; import { K8sService } from './k8s/k8s.ts'; +import { OidcHandler } from './oidc/oidc.handler.ts'; import { MqttServer } from './server/server.ts'; import { TopicsHandler } from './topics/topics.handler.ts'; import { Services } from './utils/services.ts'; @@ -38,7 +40,8 @@ class Backbone { public start = async () => { if (this.config.k8s.enabled) { - await this.setupK8sOperator(); + await this.k8s.setup(); + this.accessHandler.register('k8s', this.k8s.clients); } if (this.config.http.enabled) { console.log('starting http'); @@ -49,11 +52,12 @@ class Backbone { const tcp = this.server.getTcpServer(); tcp.listen(this.config.tcp.port); } - }; - - public setupK8sOperator = async () => { - await this.k8s.setup(); - this.accessHandler.register('k8s', this.k8s.clients); + if (this.config.oidc.enabled) { + this.accessHandler.register('oidc', this.#services.get(OidcHandler)); + } + if (this.config.tokenSecret) { + this.accessHandler.register('token', this.#services.get(AccessTokens)); + } }; public destroy = async () => { diff --git a/src/config/config.ts b/src/config/config.ts index be2c2a6..b8a95e3 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -3,6 +3,29 @@ class Config { return process.env.TOKEN_SECRET; } + public get oidc() { + const enabled = process.env.OIDC_ENABLED === 'true'; + const discoveryUrl = process.env.OIDC_DISCOVERY_URL; + const clientId = process.env.OIDC_CLIENT_ID; + const clientSecret = process.env.OIDC_CLIENT_SECRET; + const groupField = process.env.OIDC_GROUP_FIELD || 'groups'; + const adminGroup = process.env.OIDC_ADMIN_GROUP; + const writerGroup = process.env.OIDC_WRITER_GROUP; + const readerGroup = process.env.OIDC_READER_GROUP; + return { + enabled, + discoveryUrl, + clientId, + clientSecret, + groupField, + groups: { + admin: adminGroup, + writer: writerGroup, + reader: readerGroup, + }, + }; + } + public get k8s() { const enabled = process.env.K8S_ENABLED === 'true'; return { diff --git a/src/oidc/oidc.handler.ts b/src/oidc/oidc.handler.ts new file mode 100644 index 0000000..6d2a839 --- /dev/null +++ b/src/oidc/oidc.handler.ts @@ -0,0 +1,66 @@ +import jwt from 'jsonwebtoken'; + +import type { AccessProvider } from '#root/access/access.provider.ts'; +import type { Services } from '#root/utils/services.ts'; +import type { Statement } from '#root/access/access.schemas.ts'; +import { Config } from '#root/config/config.ts'; + +const adminStatements: Statement[] = [ + { + effect: 'allow', + resources: ['**'], + actions: ['**'], + }, +]; +const writerStatements: Statement[] = [ + { + effect: 'allow', + resources: ['**'], + actions: ['mqtt:**'], + }, +]; +const readerStatements: Statement[] = [ + { + effect: 'allow', + resources: ['**'], + actions: ['mqtt:read', 'mqtt:subscribe'], + }, +]; + +class OidcHandler implements AccessProvider { + #services: Services; + + constructor(services: Services) { + this.#services = services; + } + + public getAccess = async (token: string) => { + const data = jwt.decode(token); + const config = this.#services.get(Config); + if (!data || typeof data !== 'object') { + throw new Error('JWT body malformed'); + } + // TODO: Validate signature against issuer!!! + if (data.exp && data.exp > Date.now() / 1000) { + throw new Error('JWT token is expired'); + } + let statements: Statement[] = []; + const groups = data[config.oidc.groupField]; + if (Array.isArray(groups)) { + if (config.oidc.groups.admin && groups.includes(config.oidc.groups.admin)) { + statements = adminStatements; + } + if (config.oidc.groups.writer && groups.includes(config.oidc.groups.writer)) { + statements = writerStatements; + } + if (config.oidc.groups.reader && groups.includes(config.oidc.groups.reader)) { + statements = readerStatements; + } + } + return { + statements, + }; + }; +} + +export { OidcHandler };