feat: initial OIDC support

This commit is contained in:
Morten Olsen
2025-10-16 15:55:00 +02:00
parent fc159e6d3e
commit 7c30e43ef7
4 changed files with 116 additions and 17 deletions

View File

@@ -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<typeof tokenBodySchema>;
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;
};

View File

@@ -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 () => {

View File

@@ -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 {

66
src/oidc/oidc.handler.ts Normal file
View File

@@ -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 };