From 26b58a59c0a148488d110d563b4deb753d6839d3 Mon Sep 17 00:00:00 2001 From: Morten Olsen Date: Fri, 1 Aug 2025 14:40:16 +0200 Subject: [PATCH] lot of updates --- src/crds/authentik/client/client.ts | 55 +++- src/crds/authentik/server/server.schema.ts | 13 + src/crds/authentik/server/server.setup.ts | 250 ++++++++++++++++++ src/crds/authentik/server/server.ts | 18 ++ src/crds/domain/endpoint/endpoint.ts | 3 +- src/custom-resource/custom-resource.base.ts | 6 +- .../custom-resource.registry.ts | 117 ++++---- src/index.ts | 29 +- src/services/authentik/authentik.instance.ts | 225 ++++++++++++++++ src/services/authentik/authentik.service.ts | 228 +--------------- src/services/authentik/authentik.setup.ts | 135 ---------- src/services/authentik/authentik.types.ts | 10 +- src/services/k8s.ts | 19 ++ src/services/k8s/k8s.manifest.ts | 4 + src/services/log/log.ts | 9 + src/utils/consts.ts | 8 +- 16 files changed, 694 insertions(+), 435 deletions(-) create mode 100644 src/crds/authentik/server/server.schema.ts create mode 100644 src/crds/authentik/server/server.setup.ts create mode 100644 src/services/authentik/authentik.instance.ts delete mode 100644 src/services/authentik/authentik.setup.ts diff --git a/src/crds/authentik/client/client.ts b/src/crds/authentik/client/client.ts index c0977ab..e0fab58 100644 --- a/src/crds/authentik/client/client.ts +++ b/src/crds/authentik/client/client.ts @@ -3,8 +3,14 @@ import { z } from 'zod'; import { CustomResource, type CustomResourceHandlerOptions } from '../../../custom-resource/custom-resource.base.ts'; import { AuthentikService } from '../../../services/authentik/authentik.service.ts'; +import { K8sService } from '../../../services/k8s.ts'; +import { GROUP } from '../../../utils/consts.ts'; const authentikClientSpec = z.object({ + authentik: z.object({ + name: z.string(), + namespace: z.string().optional(), + }), subMode: z.enum(SubModeEnum).optional(), clientType: z.enum(['confidential', 'public']).optional(), redirectUris: z.array( @@ -32,6 +38,46 @@ class AuthentikClient extends CustomResource { public update = async (options: CustomResourceHandlerOptions) => { const { request, services, ensureSecret } = options; + const k8s = services.get(K8sService); + const { spec } = request; + + const serverNamespace = spec.authentik.namespace ?? request.metadata.namespace ?? 'default'; + + const server = await k8s.get({ + apiVersion: `${GROUP}/v1`, + kind: 'AuthentikServer', + namespace: serverNamespace, + name: spec.authentik.name, + }); + + if (!server) { + throw new Error(`AuthentikServer ${spec.authentik.name} not found in namespace ${serverNamespace}`); + } + + const serverSecret = await k8s.getSecret<{ + token: string; + }>(spec.authentik.name, spec.authentik.namespace); + if (!serverSecret) { + throw new Error( + `Secret for AuthentikServer ${spec.authentik.name} not found in namespace ${spec.authentik.namespace}`, + ); + } + + const domainNamespace = server.spec.domain.namespace || server.metadata.namespace || 'default'; + + const domain = await k8s.get({ + apiVersion: `${GROUP}/v1`, + kind: 'Domain', + name: server.spec.domain.name, + namespace: domainNamespace, + }); + + if (!domain) { + throw new Error(`Domain ${server.spec.domain.name} not found in namespace ${domainNamespace}`); + } + + const internalUrl = `http://${server.metadata.name}.${spec.authentik.namespace || 'default'}.svc.cluster.local:9000`; + const externalUrl = `https://${server.spec.subdomain}.${domain.spec.domain}`; const authentikService = services.get(AuthentikService); const { clientSecret } = await ensureSecret({ name: `authentik-client-${request.metadata.name}`, @@ -41,7 +87,14 @@ class AuthentikClient extends CustomResource { clientSecret: crypto.randomUUID(), }), }); - const client = await authentikService.upsertClient({ + const authentik = await authentikService.get({ + url: { + internal: internalUrl, + external: externalUrl, + }, + token: serverSecret.token, + }); + const client = await authentik.upsertClient({ name: request.metadata.name, secret: clientSecret, subMode: request.spec.subMode, diff --git a/src/crds/authentik/server/server.schema.ts b/src/crds/authentik/server/server.schema.ts new file mode 100644 index 0000000..4492393 --- /dev/null +++ b/src/crds/authentik/server/server.schema.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +const authentikServerSpecSchema = z.object({ + domain: z.object({ + name: z.string(), + namespace: z.string().optional(), + }), + subdomain: z.string(), +}); + +type AuthentikServerSpec = z.infer; + +export { authentikServerSpecSchema, type AuthentikServerSpec }; diff --git a/src/crds/authentik/server/server.setup.ts b/src/crds/authentik/server/server.setup.ts new file mode 100644 index 0000000..b8bac67 --- /dev/null +++ b/src/crds/authentik/server/server.setup.ts @@ -0,0 +1,250 @@ +import z from 'zod'; + +import type { CustomResourceHandlerOptions } from '../../../custom-resource/custom-resource.base.ts'; +import { K8sService } from '../../../services/k8s.ts'; +import { PostgresService } from '../../../services/postgres/postgres.service.ts'; +import { FIELDS, GROUP } from '../../../utils/consts.ts'; + +import type { authentikServerSpecSchema } from './server.schema.ts'; + +const toPostgresSafeName = (inputString: string): string => { + let safeName = inputString.toLowerCase(); + safeName = safeName.replace(/[^a-z0-9_]/g, '_'); + safeName = safeName.replace(/^_+|_+$/g, ''); + if (safeName === '') { + return 'default_name'; // Or throw new Error("Input resulted in an empty safe name."); + } + + if (/^[0-9]/.test(safeName)) { + safeName = '_' + safeName; + } + + const MAX_PG_IDENTIFIER_LENGTH = 63; + if (safeName.length > MAX_PG_IDENTIFIER_LENGTH) { + safeName = safeName.substring(0, MAX_PG_IDENTIFIER_LENGTH); + } + + return safeName; +}; + +const setupAuthentik = async ({ + services, + request, + ensureSecret, +}: CustomResourceHandlerOptions) => { + const { name, namespace } = request.metadata; + + const k8sService = services.get(K8sService); + const postgresService = services.get(PostgresService); + + const domainNamespace = request.spec.domain.namespace || namespace || 'default'; + + const domain = await k8sService.get({ + apiVersion: `${GROUP}/v1`, + kind: 'Domain', + name: request.spec.domain.name, + namespace: domainNamespace, + }); + + if (!domain) { + throw new Error(`Domain ${request.spec.domain.name} not found in namespace ${domainNamespace || 'default'}`); + } + + const secretData = await ensureSecret({ + name: name, + namespace: namespace || 'default', + schema: z.object({ + secret: z.string(), + token: z.string(), + password: z.string(), + }), + generator: async () => ({ + secret: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('hex'), + token: Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('hex'), + password: Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('hex'), + }), + }); + + const hostname = `${request.spec.subdomain}.${domain.spec.domain}`; + + const db = { + name: toPostgresSafeName(`${namespace}_${name}`), + user: toPostgresSafeName(`${namespace}_${name}_user`), + password: 'sdf908sad0sdf7g98', + }; + + await postgresService.upsertRole({ + name: db.user, + password: db.password, + }); + + await postgresService.upsertDatabase({ + name: db.name, + owner: db.user, + }); + + const createManifest = (command: string) => ({ + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { + name: `${name}-${command}`, + namespace: namespace, + labels: { + 'app.kubernetes.io/name': `${name}-${command}`, + 'argocd.argoproj.io/instance': 'homelab', + }, + annotations: { + [FIELDS.domain.domainId]: domain.dependencyId, + }, + ownerReferences: [request.objectRef], + }, + spec: { + replicas: 1, + selector: { + matchLabels: { + 'app.kubernetes.io/name': `${name}-${command}`, + }, + }, + template: { + metadata: { + labels: { + 'app.kubernetes.io/name': `${name}-${command}`, + }, + }, + spec: { + containers: [ + { + name: `${name}-${command}`, + image: 'ghcr.io/goauthentik/server:2025.6.4', + // imagePullPolicy: 'ifNot' + args: [command], + env: [ + { name: 'AUTHENTIK_SECRET_KEY', value: secretData.secret }, + { name: 'AUTHENTIK_POSTGRESQL__HOST', value: 'postgres-postgresql.postgres.svc.cluster.local' }, + { + name: 'AUTHENTIK_POSTGRESQL__PORT', + value: '5432', + }, + { + name: 'AUTHENTIK_POSTGRESQL__NAME', + value: db.name, + }, + { + name: 'AUTHENTIK_POSTGRESQL__USER', + value: db.user, + }, + { + name: 'AUTHENTIK_POSTGRESQL__PASSWORD', + value: db.password, + }, + { + name: 'AUTHENTIK_REDIS__HOST', + value: 'redis.redis.svc.cluster.local', + }, + { + name: 'AUTHENTIK_BOOTSTRAP_PASSWORD', + value: secretData.password, + }, + { + name: 'AUTHENTIK_BOOTSTRAP_TOKEN', + value: secretData.token, + }, + { + name: 'AUTHENTIK_BOOTSTRAP_EMAIL', + value: `admin@${hostname}`, + }, + // { + // name: 'AUTHENTIK_REDIS__PORT', + // value: '' + // } + ], + ports: [ + { + name: 'http', + containerPort: 9000, + protocol: 'TCP', + }, + ], + }, + ], + }, + }, + }, + }); + + await k8sService.upsert(createManifest('server')); + await k8sService.upsert(createManifest('worker')); + await k8sService.upsert({ + apiVersion: 'v1', + kind: 'Service', + metadata: { + name, + namespace, + labels: { + 'app.kubernetes.io/name': `${name}-server`, + }, + ownerReferences: [request.objectRef], + }, + spec: { + type: 'ClusterIP', + ports: [ + { + port: 9000, + targetPort: 9000, + protocol: 'TCP', + name: 'http', + }, + ], + selector: { + 'app.kubernetes.io/name': `${name}-server`, + }, + }, + }); + + await k8sService.upsert({ + apiVersion: 'networking.istio.io/v1', + kind: 'DestinationRule', + metadata: { + name, + namespace, + labels: { + 'app.kubernetes.io/name': `${name}-server`, + }, + ownerReferences: [request.objectRef], + }, + spec: { + host: `${name}.${namespace || 'default'}.svc.cluster.local`, + trafficPolicy: { + tls: { + mode: 'DISABLE', + }, + }, + }, + }); + + await k8sService.upsert({ + apiVersion: `${GROUP}/v1`, + kind: 'DomainEndpoint', + metadata: { + name: request.metadata.name, + namespace: request.metadata.namespace ?? 'default', + labels: { + 'app.kubernetes.io/name': `${name}-domain-endpoint`, + }, + ownerReferences: [request.objectRef], + }, + spec: { + domain: 'homelab/homelab', + subdomain: request.spec.subdomain, + destination: { + name, + namespace: namespace ?? 'default', + port: { + number: 9000, + }, + }, + }, + }); +}; + +export { setupAuthentik }; diff --git a/src/crds/authentik/server/server.ts b/src/crds/authentik/server/server.ts index e69de29..e1c4177 100644 --- a/src/crds/authentik/server/server.ts +++ b/src/crds/authentik/server/server.ts @@ -0,0 +1,18 @@ +import { createCustomResource } from '../../../custom-resource/custom-resource.base.ts'; + +import { authentikServerSpecSchema } from './server.schema.ts'; +import { setupAuthentik } from './server.setup.ts'; + +const AuthentikServer = createCustomResource({ + kind: 'AuthentikServer', + names: { + plural: 'authentikservers', + singular: 'authentikserver', + }, + spec: authentikServerSpecSchema, + update: async (options) => { + await setupAuthentik(options); + }, +}); + +export { AuthentikServer }; diff --git a/src/crds/domain/endpoint/endpoint.ts b/src/crds/domain/endpoint/endpoint.ts index 9809a49..451ed61 100644 --- a/src/crds/domain/endpoint/endpoint.ts +++ b/src/crds/domain/endpoint/endpoint.ts @@ -64,7 +64,8 @@ const DomainEndpoint = createCustomResource({ route: [ { destination: { - host: `${request.spec.destination.name}.${request.spec.destination.namespace || request.metadata.namespace || 'homelab'}.svc.cluster.local`, + host: `${request.spec.destination.name}.${request.spec.destination.namespace || request.metadata.namespace || 'default'}.svc.cluster.local`, + protocol: 'HTTP', port: request.spec.destination.port, }, }, diff --git a/src/custom-resource/custom-resource.base.ts b/src/custom-resource/custom-resource.base.ts index a7686dc..906d032 100644 --- a/src/custom-resource/custom-resource.base.ts +++ b/src/custom-resource/custom-resource.base.ts @@ -2,7 +2,6 @@ import { z, type ZodObject } from 'zod'; import { GROUP } from '../utils/consts.ts'; import type { Services } from '../utils/service.ts'; -import { noopAsync } from '../utils/types.ts'; import { customResourceStatusSchema, type CustomResourceRequest } from './custom-resource.request.ts'; @@ -61,9 +60,10 @@ abstract class CustomResource { return this.#options.names; } - public abstract update(options: CustomResourceHandlerOptions): Promise; + public update?(options: CustomResourceHandlerOptions): Promise; public create?(options: CustomResourceHandlerOptions): Promise; public delete?(options: CustomResourceHandlerOptions): Promise; + public reconcile?(options: CustomResourceHandlerOptions): Promise; public toManifest = () => { return { @@ -124,7 +124,7 @@ const createCustomResource = ( super(options); } - public update = options.update ?? noopAsync; + public update = options.update; public create = options.create; public delete = options.delete; }; diff --git a/src/custom-resource/custom-resource.registry.ts b/src/custom-resource/custom-resource.registry.ts index fa6e234..8a752ab 100644 --- a/src/custom-resource/custom-resource.registry.ts +++ b/src/custom-resource/custom-resource.registry.ts @@ -1,5 +1,5 @@ import { ApiException, Watch } from '@kubernetes/client-node'; -import type { ZodObject } from 'zod'; +import type { z, ZodObject } from 'zod'; import { K8sService } from '../services/k8s.ts'; import type { Services } from '../utils/service.ts'; @@ -67,66 +67,66 @@ class CustomResourceRegistry { #ensureSecret = (request: CustomResourceRequest) => - async (options: EnsureSecretOptions) => { - const { schema, name, namespace, generator } = options; - const { metadata } = request; - const k8sService = this.#services.get(K8sService); - let exists = false; - try { - const secret = await k8sService.api.readNamespacedSecret({ - name, - namespace, - }); + async (options: EnsureSecretOptions): Promise> => { + const { schema, name, namespace, generator } = options; + const { metadata } = request; + const k8sService = this.#services.get(K8sService); + let exists = false; + try { + const secret = await k8sService.api.readNamespacedSecret({ + name, + namespace, + }); - exists = true; - if (secret?.data) { - const decoded = Object.fromEntries( - Object.entries(secret.data).map(([key, value]) => [key, Buffer.from(value, 'base64').toString('utf-8')]), - ); - if (schema.safeParse(decoded).success) { - return decoded; - } - } - } catch (error) { - if (!(error instanceof ApiException && error.code === 404)) { - throw error; + exists = true; + if (secret?.data) { + const decoded = Object.fromEntries( + Object.entries(secret.data).map(([key, value]) => [key, Buffer.from(value, 'base64').toString('utf-8')]), + ); + if (schema.safeParse(decoded).success) { + return decoded as z.infer; } } - const value = await generator(); - const data = Object.fromEntries( - Object.entries(value).map(([key, value]) => [key, Buffer.from(value as string).toString('base64')]), - ); - const body = { - kind: 'Secret', - metadata: { - name, - namespace, - ownerReferences: [ - { - apiVersion: request.apiVersion, - kind: request.kind, - name: metadata.name, - uid: metadata.uid, - }, - ], - }, - type: 'Opaque', - data, - }; - if (exists) { - await k8sService.api.replaceNamespacedSecret({ - name, - namespace, - body, - }); - } else { - const response = await k8sService.api.createNamespacedSecret({ - namespace, - body, - }); - return response.data; + } catch (error) { + if (!(error instanceof ApiException && error.code === 404)) { + throw error; } + } + const value = await generator(); + const data = Object.fromEntries( + Object.entries(value).map(([key, value]) => [key, Buffer.from(value as string).toString('base64')]), + ); + const body = { + kind: 'Secret', + metadata: { + name, + namespace, + ownerReferences: [ + { + apiVersion: request.apiVersion, + kind: request.kind, + name: metadata.name, + uid: metadata.uid, + }, + ], + }, + type: 'Opaque', + data, }; + if (exists) { + await k8sService.api.replaceNamespacedSecret({ + name, + namespace, + body, + }); + } else { + await k8sService.api.createNamespacedSecret({ + namespace, + body, + }); + } + return value; + }; public get objects() { return Array.from(this.#cache.values()); @@ -154,6 +154,11 @@ class CustomResourceRegistry { observedGeneration: status.observedGeneration, generation: metadata.generation, }); + await crd.reconcile?.({ + request, + services: this.#services, + ensureSecret: this.#ensureSecret(request) as ExpectedAny, + }); return; } } diff --git a/src/index.ts b/src/index.ts index 17252ec..bc49ded 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,24 +5,10 @@ import { CustomResourceRegistry } from './custom-resource/custom-resource.regist import { Services } from './utils/service.ts'; import { SecretRequest } from './crds/secrets/secrets.request.ts'; import { PostgresDatabase } from './crds/postgres/postgres.database.ts'; -import { AuthentikService } from './services/authentik/authentik.service.ts'; import { AuthentikClient } from './crds/authentik/client/client.ts'; import { Domain } from './crds/domain/domain/domain.ts'; import { DomainEndpoint } from './crds/domain/endpoint/endpoint.ts'; - -const services = new Services(); - -//const authentikService = services.get(AuthentikService); -//await authentikService.ready(); - -const registry = services.get(CustomResourceRegistry); -registry.register(new SecretRequest()); -registry.register(new PostgresDatabase()); -registry.register(new AuthentikClient()); -registry.register(new Domain()); -registry.register(new DomainEndpoint()); -await registry.install(true); -await registry.watch(); +import { AuthentikServer } from './crds/authentik/server/server.ts'; process.on('uncaughtException', (error) => { console.log('UNCAUGHT EXCEPTION'); @@ -45,3 +31,16 @@ process.on('unhandledRejection', (error) => { console.error(error); process.exit(1); }); + +const services = new Services(); +const registry = services.get(CustomResourceRegistry); + +registry.register(new SecretRequest()); +registry.register(new PostgresDatabase()); +registry.register(new AuthentikServer()); +registry.register(new AuthentikClient()); +registry.register(new Domain()); +registry.register(new DomainEndpoint()); + +await registry.install(true); +await registry.watch(); diff --git a/src/services/authentik/authentik.instance.ts b/src/services/authentik/authentik.instance.ts new file mode 100644 index 0000000..501a530 --- /dev/null +++ b/src/services/authentik/authentik.instance.ts @@ -0,0 +1,225 @@ +import { createAuthentikClient, type AuthentikClient } from '../../clients/authentik/authentik.ts'; +import type { Services } from '../../utils/service.ts'; + +import type { AuthentikServerInfo, UpsertClientRequest, UpsertGroupRequest } from './authentik.types.ts'; + +type AuthentikInstanceOptions = { + info: AuthentikServerInfo; + services: Services; +}; + +const DEFAULT_AUTHORIZATION_FLOW = 'default-provider-authorization-implicit-consent'; +const DEFAULT_INVALIDATION_FLOW = 'default-invalidation-flow'; +const DEFAULT_SCOPES = ['openid', 'email', 'profile', 'offline_access']; + +class AuthentikInstance { + #options: AuthentikInstanceOptions; + #client: AuthentikClient; + + constructor(options: AuthentikInstanceOptions) { + this.#options = options; + const baseUrl = new URL('/api/v3', options.info.url.internal).toString(); + options.services.log.debug('Using Authentik base URL', { baseUrl }); + this.#client = createAuthentikClient({ + baseUrl, + token: options.info.token, + }); + } + + #upsertApplication = async (request: UpsertClientRequest, provider: number, pk?: string) => { + const client = this.#client; + if (!pk) { + return await client.core.coreApplicationsCreate({ + applicationRequest: { + name: request.name, + slug: request.name, + provider, + }, + }); + } + return await client.core.coreApplicationsUpdate({ + slug: request.name, + applicationRequest: { + name: request.name, + slug: request.name, + provider, + }, + }); + }; + + #upsertProvider = async (request: UpsertClientRequest, pk?: number) => { + const flows = await this.getFlows(); + const authorizationFlow = flows.results.find( + (flow) => flow.slug === (request.flows?.authorization ?? DEFAULT_AUTHORIZATION_FLOW), + ); + const invalidationFlow = flows.results.find( + (flow) => flow.slug === (request.flows?.invalidation ?? DEFAULT_INVALIDATION_FLOW), + ); + if (!authorizationFlow || !invalidationFlow) { + throw new Error('Authorization and invalidation flows not found'); + } + const scopes = await this.getScopePropertyMappings(); + const scopePropertyMapping = (request.scopes ?? DEFAULT_SCOPES) + .map((scope) => scopes.results.find((mapping) => mapping.scopeName === scope)?.pk) + .filter(Boolean) as string[]; + + const client = this.#client; + + if (!pk) { + return await client.providers.providersOauth2Create({ + oAuth2ProviderRequest: { + name: request.name, + clientId: request.name, + clientSecret: request.secret, + redirectUris: request.redirectUris, + authorizationFlow: authorizationFlow.pk, + invalidationFlow: invalidationFlow.pk, + propertyMappings: scopePropertyMapping, + clientType: request.clientType, + subMode: request.subMode, + accessCodeValidity: request.timing?.accessCodeValidity, + accessTokenValidity: request.timing?.accessTokenValidity, + refreshTokenValidity: request.timing?.refreshTokenValidity, + }, + }); + } + return await client.providers.providersOauth2Update({ + id: pk, + oAuth2ProviderRequest: { + name: request.name, + clientId: request.name, + clientSecret: request.secret, + redirectUris: request.redirectUris, + authorizationFlow: authorizationFlow.pk, + invalidationFlow: invalidationFlow.pk, + propertyMappings: scopePropertyMapping, + clientType: request.clientType, + subMode: request.subMode, + accessCodeValidity: request.timing?.accessCodeValidity, + accessTokenValidity: request.timing?.accessTokenValidity, + refreshTokenValidity: request.timing?.refreshTokenValidity, + }, + }); + }; + + public getGroupFromName = async (name: string) => { + const client = this.#client; + const groups = await client.core.coreGroupsList({ + search: name, + }); + return groups.results.find((group) => group.name === name); + }; + + public getScopePropertyMappings = async () => { + const client = this.#client; + const mappings = await client.propertymappings.propertymappingsProviderScopeList({}); + return mappings; + }; + + public getApplicationFromSlug = async (slug: string) => { + const client = this.#client; + const applications = await client.core.coreApplicationsList({ + search: slug, + }); + const application = applications.results.find((app) => app.slug === slug); + return application; + }; + + public getProviderFromClientId = async (clientId: string) => { + const client = this.#client; + + const providers = await client.providers.providersOauth2List({ + clientId, + }); + return providers.results.find((provider) => provider.clientId === clientId); + }; + + public getFlows = async () => { + const client = this.#client; + const flows = await client.flows.flowsInstancesList(); + return flows; + }; + + public upsertClient = async (request: UpsertClientRequest) => { + const url = this.#options.info.url.external; + try { + let provider = await this.getProviderFromClientId(request.name); + provider = await this.#upsertProvider(request, provider?.pk); + let application = await this.getApplicationFromSlug(request.name); + application = await this.#upsertApplication(request, provider.pk, application?.pk); + const config = { + provider: { + id: provider.pk, + name: provider.name, + clientId: provider.clientId, + clientSecret: provider.clientSecret, + clientType: provider.clientType, + subMode: provider.subMode, + redirectUris: provider.redirectUris, + scopes: provider.propertyMappings, + timing: { + accessCodeValidity: provider.accessCodeValidity, + accessTokenValidity: provider.accessTokenValidity, + refreshTokenValidity: provider.refreshTokenValidity, + }, + }, + application: { + id: application.pk, + name: application.name, + slug: application.slug, + provider: provider.pk, + }, + urls: { + configuration: new URL(`/application/o/${provider.name}/.well-known/openid-configuration`, url).toString(), + configurationIssuer: new URL(`/application/o/${provider.name}/`, url).toString(), + authorization: new URL(`/application/o/${provider.name}/authorize/`, url).toString(), + token: new URL(`/application/o/${provider.name}/token/`, url).toString(), + userinfo: new URL(`/application/o/${provider.name}/userinfo/`, url).toString(), + endSession: new URL(`/application/o/${provider.name}/end-session/`, url).toString(), + jwks: new URL(`/application/o/${provider.name}/jwks/`, url).toString(), + }, + }; + return { provider, application, config }; + } catch (error: ExpectedAny) { + if ('response' in error) { + throw new Error(await error.response.text()); + } + throw error; + } + }; + + public deleteClient = async (name: string) => { + const provider = await this.getProviderFromClientId(name); + const client = this.#client; + if (provider) { + await client.providers.providersOauth2Destroy({ id: provider.pk }); + } + const application = await this.getApplicationFromSlug(name); + if (application) { + await client.core.coreApplicationsDestroy({ slug: application.name }); + } + }; + + public upsertGroup = async (request: UpsertGroupRequest) => { + const group = await this.getGroupFromName(request.name); + const client = this.#client; + if (!group) { + await client.core.coreGroupsCreate({ + groupRequest: { + name: request.name, + attributes: request.attributes, + }, + }); + } else { + await client.core.coreGroupsUpdate({ + groupUuid: group.pk, + groupRequest: { + name: request.name, + attributes: request.attributes, + }, + }); + } + }; +} + +export { AuthentikInstance, type AuthentikInstanceOptions }; diff --git a/src/services/authentik/authentik.service.ts b/src/services/authentik/authentik.service.ts index e0dcc01..109fecd 100644 --- a/src/services/authentik/authentik.service.ts +++ b/src/services/authentik/authentik.service.ts @@ -1,237 +1,21 @@ import type { Services } from '../../utils/service.ts'; -import { createAuthentikClient, type AuthentikClient } from '../../clients/authentik/authentik.ts'; -import type { UpsertClientRequest, UpsertGroupRequest } from './authentik.types.ts'; -import { setupAuthentik } from './authentik.setup.ts'; - -const DEFAULT_AUTHORIZATION_FLOW = 'default-provider-authorization-implicit-consent'; -const DEFAULT_INVALIDATION_FLOW = 'default-invalidation-flow'; -const DEFAULT_SCOPES = ['openid', 'email', 'profile', 'offline_access']; +import type { AuthentikServerInfo } from './authentik.types.ts'; +import { AuthentikInstance } from './authentik.instance.ts'; class AuthentikService { #services: Services; - #init?: Promise; constructor(services: Services) { this.#services = services; } - public getPublicUrl = async () => { - return ''; - }; - - #getClient = () => { - if (!this.#init) { - this.#init = this.#create(); - } - return this.#init; - }; - - #upsertApplication = async (request: UpsertClientRequest, provider: number, pk?: string) => { - const client = await this.#getClient(); - if (!pk) { - return await client.core.coreApplicationsCreate({ - applicationRequest: { - name: request.name, - slug: request.name, - provider, - }, - }); - } - return await client.core.coreApplicationsUpdate({ - slug: request.name, - applicationRequest: { - name: request.name, - slug: request.name, - provider, - }, + public get = async (info: AuthentikServerInfo) => { + return new AuthentikInstance({ + info, + services: this.#services, }); }; - - #upsertProvider = async (request: UpsertClientRequest, pk?: number) => { - const flows = await this.getFlows(); - const authorizationFlow = flows.results.find( - (flow) => flow.slug === (request.flows?.authorization ?? DEFAULT_AUTHORIZATION_FLOW), - ); - const invalidationFlow = flows.results.find( - (flow) => flow.slug === (request.flows?.invalidation ?? DEFAULT_INVALIDATION_FLOW), - ); - if (!authorizationFlow || !invalidationFlow) { - throw new Error('Authorization and invalidation flows not found'); - } - const scopes = await this.getScopePropertyMappings(); - const scopePropertyMapping = (request.scopes ?? DEFAULT_SCOPES) - .map((scope) => scopes.results.find((mapping) => mapping.scopeName === scope)?.pk) - .filter(Boolean) as string[]; - - const client = await this.#getClient(); - - if (!pk) { - return await client.providers.providersOauth2Create({ - oAuth2ProviderRequest: { - name: request.name, - clientId: request.name, - clientSecret: request.secret, - redirectUris: request.redirectUris, - authorizationFlow: authorizationFlow.pk, - invalidationFlow: invalidationFlow.pk, - propertyMappings: scopePropertyMapping, - clientType: request.clientType, - subMode: request.subMode, - accessCodeValidity: request.timing?.accessCodeValidity, - accessTokenValidity: request.timing?.accessTokenValidity, - refreshTokenValidity: request.timing?.refreshTokenValidity, - }, - }); - } - return await client.providers.providersOauth2Update({ - id: pk, - oAuth2ProviderRequest: { - name: request.name, - clientId: request.name, - clientSecret: request.secret, - redirectUris: request.redirectUris, - authorizationFlow: authorizationFlow.pk, - invalidationFlow: invalidationFlow.pk, - propertyMappings: scopePropertyMapping, - clientType: request.clientType, - subMode: request.subMode, - accessCodeValidity: request.timing?.accessCodeValidity, - accessTokenValidity: request.timing?.accessTokenValidity, - refreshTokenValidity: request.timing?.refreshTokenValidity, - }, - }); - }; - - #create = async () => { - const { url, token } = await setupAuthentik(this.#services); - return createAuthentikClient({ - baseUrl: new URL('api/v3', url).toString(), - token: token, - }); - }; - - public getGroupFromName = async (name: string) => { - const client = await this.#getClient(); - const groups = await client.core.coreGroupsList({ - search: name, - }); - return groups.results.find((group) => group.name === name); - }; - - public getScopePropertyMappings = async () => { - const client = await this.#getClient(); - const mappings = await client.propertymappings.propertymappingsProviderScopeList({}); - return mappings; - }; - - public getApplicationFromSlug = async (slug: string) => { - const client = await this.#getClient(); - const applications = await client.core.coreApplicationsList({ - search: slug, - }); - const application = applications.results.find((app) => app.slug === slug); - return application; - }; - - public getProviderFromClientId = async (clientId: string) => { - const client = await this.#getClient(); - const providers = await client.providers.providersOauth2List({ - clientId, - }); - return providers.results.find((provider) => provider.clientId === clientId); - }; - - public getFlows = async () => { - const client = await this.#getClient(); - const flows = await client.flows.flowsInstancesList(); - return flows; - }; - - public upsertClient = async (request: UpsertClientRequest) => { - const url = await this.getPublicUrl(); - try { - let provider = await this.getProviderFromClientId(request.name); - provider = await this.#upsertProvider(request, provider?.pk); - let application = await this.getApplicationFromSlug(request.name); - application = await this.#upsertApplication(request, provider.pk, application?.pk); - const config = { - provider: { - id: provider.pk, - name: provider.name, - clientId: provider.clientId, - clientSecret: provider.clientSecret, - clientType: provider.clientType, - subMode: provider.subMode, - redirectUris: provider.redirectUris, - scopes: provider.propertyMappings, - timing: { - accessCodeValidity: provider.accessCodeValidity, - accessTokenValidity: provider.accessTokenValidity, - refreshTokenValidity: provider.refreshTokenValidity, - }, - }, - application: { - id: application.pk, - name: application.name, - slug: application.slug, - provider: provider.pk, - }, - urls: { - configuration: new URL(`/application/o/${provider.name}/.well-known/openid-configuration`, url).toString(), - configurationIssuer: new URL(`/application/o/${provider.name}/`, url).toString(), - authorization: new URL(`/application/o/${provider.name}/authorize/`, url).toString(), - token: new URL(`/application/o/${provider.name}/token/`, url).toString(), - userinfo: new URL(`/application/o/${provider.name}/userinfo/`, url).toString(), - endSession: new URL(`/application/o/${provider.name}/end-session/`, url).toString(), - jwks: new URL(`/application/o/${provider.name}/jwks/`, url).toString(), - }, - }; - return { provider, application, config }; - } catch (error: ExpectedAny) { - if ('response' in error) { - throw new Error(await error.response.text()); - } - throw error; - } - }; - - public deleteClient = async (name: string) => { - const provider = await this.getProviderFromClientId(name); - const client = await this.#getClient(); - if (provider) { - await client.providers.providersOauth2Destroy({ id: provider.pk }); - } - const application = await this.getApplicationFromSlug(name); - if (application) { - await client.core.coreApplicationsDestroy({ slug: application.name }); - } - }; - - public upsertGroup = async (request: UpsertGroupRequest) => { - const group = await this.getGroupFromName(request.name); - const client = await this.#getClient(); - if (!group) { - await client.core.coreGroupsCreate({ - groupRequest: { - name: request.name, - attributes: request.attributes, - }, - }); - } else { - await client.core.coreGroupsUpdate({ - groupUuid: group.pk, - groupRequest: { - name: request.name, - attributes: request.attributes, - }, - }); - } - }; - - public ready = async () => { - await this.#getClient(); - }; } export { AuthentikService }; diff --git a/src/services/authentik/authentik.setup.ts b/src/services/authentik/authentik.setup.ts deleted file mode 100644 index 1a062cd..0000000 --- a/src/services/authentik/authentik.setup.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { NAMESPACE } from '../../utils/consts.ts'; -import type { Services } from '../../utils/service.ts'; -import { K8sService } from '../k8s.ts'; -import { PostgresService } from '../postgres/postgres.service.ts'; - -const SECRET = 'WkE/MDsSCe7TyIPtx/16/rwQ3XyyY9QsM450mXZklhR545PZPFoXcfrBhnxYB5jzlIwTmkg7Opgm0FDl'; // TODO: Generate and store -const setupAuthentik = async (services: Services) => { - const namespace = NAMESPACE; - const db = { - name: 'homelab_authentik', - user: 'homelab_authentik', - password: 'sdf908sad0sdf7g98', - }; - - const k8sService = services.get(K8sService); - const postgresService = services.get(PostgresService); - - await postgresService.upsertRole({ - name: db.user, - password: db.password, - }); - - await postgresService.upsertDatabase({ - name: db.name, - owner: db.user, - }); - - const createManifest = (command: string) => ({ - apiVersion: 'apps/v1', - kind: 'Deployment', - metadata: { - name: `authentik-${command}`, - namespace: namespace, - labels: { - 'app.kubernetes.io/name': `authentik-${command}`, - 'argocd.argoproj.io/instance': 'homelab', - }, - }, - spec: { - replicas: 1, - selector: { - matchLabels: { - 'app.kubernetes.io/name': `authentik-${command}`, - }, - }, - template: { - metadata: { - labels: { - 'app.kubernetes.io/name': `authentik-${command}`, - }, - }, - spec: { - containers: [ - { - name: `authentik-${command}`, - image: 'ghcr.io/goauthentik/server:2025.6.4', - // imagePullPolicy: 'ifNot' - args: [command], - env: [ - { name: 'AUTHENTIK_SECRET_KEY', value: SECRET }, - { name: 'AUTHENTIK_POSTGRESQL__HOST', value: 'postgres-postgresql.postgres.svc.cluster.local' }, - { - name: 'AUTHENTIK_POSTGRESQL__PORT', - value: '5432', - }, - { - name: 'AUTHENTIK_POSTGRESQL__NAME', - value: db.name, - }, - { - name: 'AUTHENTIK_POSTGRESQL__USER', - value: db.user, - }, - { - name: 'AUTHENTIK_POSTGRESQL__PASSWORD', - value: db.password, - }, - { - name: 'AUTHENTIK_REDIS__HOST', - value: 'redis.redis.svc.cluster.local', - }, - // { - // name: 'AUTHENTIK_REDIS__PORT', - // value: '' - // } - ], - ports: [ - { - name: 'http', - containerPort: 9000, - protocol: 'TCP', - }, - ], - }, - ], - }, - }, - }, - }); - - await k8sService.upsert(createManifest('server')); - await k8sService.upsert(createManifest('worker')); - await k8sService.upsert({ - apiVersion: 'v1', - kind: 'Service', - metadata: { - name: 'authentik', - namespace, - labels: { - 'app.kubernetes.io/name': 'authentik-server', - }, - }, - spec: { - type: 'ClusterIP', - ports: [ - { - port: 9000, - targetPort: 9000, - protocol: 'TCP', - name: 'http', - }, - ], - selector: { - 'app.kubernetes.io/name': 'authentik-server', - }, - }, - }); - - return { - url: '', - token: '', - }; -}; - -export { setupAuthentik }; diff --git a/src/services/authentik/authentik.types.ts b/src/services/authentik/authentik.types.ts index 43abe63..51a2148 100644 --- a/src/services/authentik/authentik.types.ts +++ b/src/services/authentik/authentik.types.ts @@ -1,5 +1,13 @@ import type { ClientTypeEnum, SubModeEnum } from '@goauthentik/api'; +type AuthentikServerInfo = { + url: { + internal: string; + external: string; + }; + token: string; +}; + type UpsertClientRequest = { name: string; secret: string; @@ -26,4 +34,4 @@ type UpsertGroupRequest = { attributes?: Record; }; -export type { UpsertClientRequest, UpsertGroupRequest }; +export type { AuthentikServerInfo, UpsertClientRequest, UpsertGroupRequest }; diff --git a/src/services/k8s.ts b/src/services/k8s.ts index 990516b..1039124 100644 --- a/src/services/k8s.ts +++ b/src/services/k8s.ts @@ -134,6 +134,25 @@ class K8sService { }); } }; + + public getSecret = async >(name: string, namespace?: string) => { + const current = await this.get({ + apiVersion: 'v1', + kind: 'Secret', + name, + namespace, + }); + + if (!current) { + return undefined; + } + + const { data } = current.manifest || {}; + const decodedData = Object.fromEntries( + Object.entries(data).map(([key, value]) => [key, Buffer.from(String(value), 'base64').toString('utf-8')]), + ); + return decodedData as T; + }; } export { K8sService }; diff --git a/src/services/k8s/k8s.manifest.ts b/src/services/k8s/k8s.manifest.ts index 965f0aa..782bb30 100644 --- a/src/services/k8s/k8s.manifest.ts +++ b/src/services/k8s/k8s.manifest.ts @@ -60,6 +60,10 @@ class Manifest { this.#options.manifest = obj; } + public get dependencyId() { + return `${this.metadata.uid}-${this.metadata.generation}`; + } + public get kind(): string { return this.#options.manifest.kind; } diff --git a/src/services/log/log.ts b/src/services/log/log.ts index 1acae1f..6f97aed 100644 --- a/src/services/log/log.ts +++ b/src/services/log/log.ts @@ -12,6 +12,15 @@ class LogService { }; public error = (message: string, data?: Record, root?: unknown) => { + if (root instanceof AggregateError) { + for (const error of root.errors) { + if (error instanceof Error) { + console.error(error.stack); + } else { + console.error(error); + } + } + } if (root instanceof Error) { console.log(root.stack); } diff --git a/src/utils/consts.ts b/src/utils/consts.ts index c0d6734..2f84f51 100644 --- a/src/utils/consts.ts +++ b/src/utils/consts.ts @@ -1,4 +1,10 @@ const GROUP = 'homelab.mortenolsen.pro'; const NAMESPACE = 'homelab'; -export { GROUP, NAMESPACE }; +const FIELDS = { + domain: { + domainId: `${GROUP}/domain-id`, + }, +}; + +export { GROUP, NAMESPACE, FIELDS };