add authentik

This commit is contained in:
Morten Olsen
2025-07-30 13:42:25 +02:00
parent dd1e5a8124
commit 523637d40f
27 changed files with 59686 additions and 340 deletions

View File

@@ -0,0 +1,33 @@
import {
Configuration,
CoreApi,
FlowsApi,
PropertymappingsApi,
ProvidersApi,
instanceOfErrorDetail,
} from '@goauthentik/api';
type CreateAuthentikClientOptions = {
baseUrl: string;
token: string;
};
const createAuthentikClient = ({ baseUrl, token }: CreateAuthentikClientOptions) => {
const config = new Configuration({
basePath: baseUrl,
headers: {
Authorization: `Bearer ${token}`,
},
});
const client = {
core: new CoreApi(config),
providers: new ProvidersApi(config),
propertymappings: new PropertymappingsApi(config),
flows: new FlowsApi(config),
};
return client;
};
type AuthentikClient = ReturnType<typeof createAuthentikClient>;
export { createAuthentikClient, type AuthentikClient, instanceOfErrorDetail };

58661
src/clients/authentik/authentik.types.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
import { Type } from '@sinclair/typebox';
import { SubModeEnum } from '@goauthentik/api';
import { CustomResource, type CustomResourceHandlerOptions } from '../../../custom-resource/custom-resource.base.ts';
import { AuthentikService } from '../../../services/authentik/authentik.service.ts';
const authentikClientSpec = Type.Object({
subMode: Type.Optional(Type.Unsafe<SubModeEnum>(Type.String())),
clientType: Type.Optional(
Type.Unsafe<'confidential' | 'public'>(
Type.String({
enum: ['confidential', 'public'],
}),
),
),
redirectUris: Type.Array(
Type.Object({
url: Type.String(),
matchingMode: Type.Unsafe<'strict' | 'regex'>(
Type.String({
enum: ['strict', 'regex'],
}),
),
}),
),
});
const authentikClientSecret = Type.Object({
clientSecret: Type.String(),
});
class AuthentikClient extends CustomResource<typeof authentikClientSpec> {
constructor() {
super({
kind: 'AuthentikClient',
names: {
singular: 'authentikclient',
plural: 'authentikclients',
},
spec: authentikClientSpec,
});
}
public update = async (options: CustomResourceHandlerOptions<typeof authentikClientSpec>) => {
const { request, services, ensureSecret } = options;
const authentikService = services.get(AuthentikService);
const { clientSecret } = await ensureSecret({
name: `authentik-client-${request.metadata.name}`,
namespace: request.metadata.namespace ?? 'default',
schema: authentikClientSecret,
generator: async () => ({
clientSecret: crypto.randomUUID(),
}),
});
const client = await authentikService.upsertClient({
name: request.metadata.name,
secret: clientSecret,
subMode: request.spec.subMode,
clientType: request.spec.clientType,
redirectUris: request.spec.redirectUris.map((rule) => ({
url: rule.url,
matchingMode: rule.matchingMode ?? 'strict',
})),
});
console.log(client.config);
};
}
export { AuthentikClient };

View File

@@ -1,9 +1,6 @@
import { Type } from '@sinclair/typebox';
import { ApiException, type V1Secret } from '@kubernetes/client-node';
import { CustomResource, type CustomResourceHandlerOptions } from '../../custom-resource/custom-resource.base.ts';
import { K8sService } from '../../services/k8s.ts';
import type { CustomResourceRequest } from '../../custom-resource/custom-resource.request.ts';
import { PostgresService } from '../../services/postgres/postgres.service.ts';
const postgresDatabaseSpecSchema = Type.Object({});
@@ -20,99 +17,39 @@ class PostgresDatabase extends CustomResource<typeof postgresDatabaseSpecSchema>
});
}
#getVariables = async (request: CustomResourceRequest<typeof postgresDatabaseSpecSchema>) => {
const { metadata, services } = request;
const k8sService = services.get(K8sService);
const secretName = `postgres-database-${metadata.name}`;
let secret: V1Secret | undefined;
try {
secret = await k8sService.api.readNamespacedSecret({
name: secretName,
namespace: metadata.namespace ?? 'default',
});
} catch (error) {
if (!(error instanceof ApiException && error.code === 404)) {
throw error;
}
}
if (secret && request.isOwnerOf(secret) && secret.data) {
services.log.debug('PostgresRole secret found', { secret });
return secret.data;
}
if (secret && !request.isOwnerOf(secret)) {
throw new Error('The secret is not owned by this resource');
}
const data = {
name: Buffer.from(`${metadata.namespace}_${metadata.name}`).toString('base64'),
user: Buffer.from(metadata.name).toString('base64'),
password: Buffer.from(crypto.randomUUID()).toString('base64'),
};
const namespace = metadata.namespace ?? 'default';
services.log.debug('Creating secret', { data });
const response = await k8sService.api.createNamespacedSecret({
namespace,
body: {
kind: 'Secret',
metadata: {
name: secretName,
namespace,
ownerReferences: [
{
apiVersion: request.apiVersion,
kind: request.kind,
name: metadata.name,
uid: metadata.uid,
},
],
},
type: 'Opaque',
data,
},
});
services.log.debug('Secret created', { response });
return response.data!;
};
public update = async (options: CustomResourceHandlerOptions<typeof postgresDatabaseSpecSchema>) => {
const { request, services } = options;
const status = await request.getStatus();
const { request, services, ensureSecret } = options;
const variables = await ensureSecret({
name: `postgres-database-${request.metadata.name}`,
namespace: request.metadata.namespace ?? 'default',
schema: Type.Object({
name: Type.String(),
user: Type.String(),
password: Type.String(),
}),
generator: async () => ({
name: `${request.metadata.namespace || 'default'}_${request.metadata.name}`,
user: `${request.metadata.namespace || 'default'}_${request.metadata.name}`,
password: `password_${Buffer.from(crypto.getRandomValues(new Uint8Array(12))).toString('hex')}`,
}),
});
const postgresService = services.get(PostgresService);
await postgresService.upsertRole({
name: variables.user,
password: variables.password,
});
try {
const variables = await this.#getVariables(request);
const postgresService = services.get(PostgresService);
await postgresService.upsertRole({
name: Buffer.from(variables.user!, 'base64').toString('utf-8'),
password: Buffer.from(variables.password!, 'base64').toString('utf-8'),
});
await postgresService.upsertDatabase({
name: variables.name,
owner: variables.user,
});
await postgresService.upsertDatabase({
name: Buffer.from(variables.name!, 'base64').toString('utf-8'),
owner: Buffer.from(variables.user!, 'base64').toString('utf-8'),
});
status.setCondition('Ready', {
status: 'True',
reason: 'Ready',
message: 'Role created',
});
services.log.info('PostgresRole updated', { status });
return await status.save();
} catch (error) {
const status = await request.getStatus();
status.setCondition('Ready', {
status: 'False',
reason: 'Error',
message: error instanceof Error ? error.message : 'Unknown error',
});
services.log.error('Error updating PostgresRole', { error });
return await status.save();
}
await request.addEvent({
type: 'Normal',
reason: 'DatabaseUpserted',
message: 'Database has been upserted',
action: 'UPSERT',
});
};
}

View File

@@ -1,10 +1,8 @@
import { Type } from '@sinclair/typebox';
import { ApiException, type V1Secret } from '@kubernetes/client-node';
import { CustomResource, type CustomResourceHandlerOptions } from '../../custom-resource/custom-resource.base.ts';
import { K8sService } from '../../services/k8s.ts';
const stringValueSchema = Type.String({
const stringValueSchema = Type.Object({
key: Type.String(),
chars: Type.Optional(Type.String()),
length: Type.Optional(Type.Number()),
@@ -33,71 +31,18 @@ class SecretRequest extends CustomResource<typeof secretRequestSpec> {
});
}
#createSecret = async (options: CustomResourceHandlerOptions<typeof secretRequestSpec>) => {
const { request, services } = options;
const { apiVersion, kind, spec, metadata } = request;
const { secretName = metadata.name } = spec;
const { namespace = 'default' } = metadata;
const k8sService = services.get(K8sService);
let current: V1Secret | undefined;
try {
current = await k8sService.api.readNamespacedSecret({
name: secretName,
namespace,
});
} catch (error) {
if (!(error instanceof ApiException && error.code === 404)) {
throw error;
}
}
if (current) {
services.log.debug('secret already exists', { current });
// TODO: Add update logic
return;
}
await k8sService.api.createNamespacedSecret({
namespace,
body: {
kind: 'Secret',
metadata: {
name: secretName,
namespace,
ownerReferences: [
{
apiVersion,
kind,
name: metadata.name,
uid: metadata.uid,
},
],
},
type: 'Opaque',
data: {
// TODO: generate data from spec
test: 'test',
},
},
});
};
public update = async (options: CustomResourceHandlerOptions<typeof secretRequestSpec>) => {
const { request } = options;
const status = await request.getStatus();
try {
await this.#createSecret(options);
status.setCondition('Ready', {
status: 'True',
reason: 'SecretCreated',
message: 'Secret created',
});
return await status.save();
} catch {
status.setCondition('Ready', {
status: 'False',
reason: 'SecretNotCreated',
message: 'Secret not created',
});
}
const { request, ensureSecret } = options;
const { secretName = request.metadata.name } = request.spec;
const { namespace = request.metadata.namespace ?? 'default' } = request.metadata;
await ensureSecret({
name: secretName,
namespace,
schema: Type.Object({}, { additionalProperties: true }),
generator: async () => ({
hello: 'world',
}),
});
};
}

View File

@@ -1,13 +1,20 @@
import { type TSchema } from '@sinclair/typebox';
import { type Static, type TObject, type TSchema } from '@sinclair/typebox';
import { GROUP } from '../utils/consts.ts';
import type { Services } from '../utils/service.ts';
import { statusSchema } from './custom-resource.status.ts';
import type { CustomResourceRequest } from './custom-resource.request.ts';
import { customResourceStatusSchema, type CustomResourceRequest } from './custom-resource.request.ts';
type EnsureSecretOptions<T extends TObject> = {
schema: T;
name: string;
namespace: string;
generator: () => Promise<Static<T>>;
};
type CustomResourceHandlerOptions<TSpec extends TSchema> = {
request: CustomResourceRequest<TSpec>;
ensureSecret: <T extends TObject>(options: EnsureSecretOptions<T>) => Promise<Static<T>>;
services: Services;
};
@@ -82,7 +89,7 @@ abstract class CustomResource<TSpec extends TSchema> {
type: 'object',
properties: {
spec: this.spec,
status: statusSchema,
status: customResourceStatusSchema as ExpectedAny,
},
},
},
@@ -96,4 +103,4 @@ abstract class CustomResource<TSpec extends TSchema> {
};
}
export { CustomResource, type CustomResourceConstructor, type CustomResourceHandlerOptions };
export { CustomResource, type CustomResourceConstructor, type CustomResourceHandlerOptions, type EnsureSecretOptions };

View File

@@ -1,14 +1,15 @@
import { ApiException, Watch } from '@kubernetes/client-node';
import { type TObject } from '@sinclair/typebox';
import { K8sService } from '../services/k8s.ts';
import type { Services } from '../utils/service.ts';
import { isSchemaValid } from '../utils/schemas.ts';
import { type CustomResource } from './custom-resource.base.ts';
import { type CustomResource, type EnsureSecretOptions } from './custom-resource.base.ts';
import { CustomResourceRequest } from './custom-resource.request.ts';
class CustomResourceRegistry {
#services: Services;
#resources = new Set<CustomResource<any>>();
#resources = new Set<CustomResource<ExpectedAny>>();
#watchers = new Map<string, AbortController>();
constructor(services: Services) {
@@ -23,11 +24,11 @@ class CustomResourceRegistry {
return Array.from(this.#resources).find((r) => r.kind === kind);
};
public register = (resource: CustomResource<any>) => {
public register = (resource: CustomResource<ExpectedAny>) => {
this.#resources.add(resource);
};
public unregister = (resource: CustomResource<any>) => {
public unregister = (resource: CustomResource<ExpectedAny>) => {
this.#resources.delete(resource);
this.#watchers.forEach((controller, kind) => {
if (kind === resource.kind) {
@@ -50,7 +51,70 @@ class CustomResourceRegistry {
}
};
#onResourceEvent = async (type: string, obj: any) => {
#ensureSecret =
(request: CustomResourceRequest<ExpectedAny>) =>
async <T extends TObject>(options: EnsureSecretOptions<T>) => {
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 (isSchemaValid(schema, decoded)) {
return decoded;
}
}
} 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 {
const response = await k8sService.api.createNamespacedSecret({
namespace,
body,
});
return response.data;
}
};
#onResourceEvent = async (type: string, obj: ExpectedAny) => {
const { kind } = obj;
const crd = this.getByKind(kind);
if (!crd) {
@@ -65,45 +129,101 @@ class CustomResourceRegistry {
});
const status = await request.getStatus();
if (status.observedGeneration === obj.metadata.generation) {
this.#services.log.debug('Skipping resource update', {
observedGeneration: status.observedGeneration,
generation: obj.metadata.generation,
});
return;
if (status && (type === 'ADDED' || type === 'MODIFIED')) {
if (status.observedGeneration === obj.metadata.generation) {
this.#services.log.debug('Skipping resource update', {
kind,
name: obj.metadata.name,
namespace: obj.metadata.namespace,
observedGeneration: status.observedGeneration,
generation: obj.metadata.generation,
});
return;
}
}
this.#services.log.debug('Updating resource', {
type,
kind,
name: obj.metadata.name,
namespace: obj.metadata.namespace,
observedGeneration: status?.observedGeneration,
generation: obj.metadata.generation,
});
if (type === 'ADDED' || type === 'MODIFIED') {
await request.markSeen();
}
if (type === 'ADDED' && crd.create) {
handler = crd.create;
}
await handler?.({
request,
services: this.#services,
});
try {
await handler?.({
request,
services: this.#services,
ensureSecret: this.#ensureSecret(request) as ExpectedAny,
});
if (type === 'ADDED' || type === 'MODIFIED') {
await request.setCondition({
type: 'Ready',
status: 'True',
message: 'Resource created',
});
}
} catch (error) {
let message = 'Unknown error';
if (error instanceof ApiException) {
message = error.body;
this.#services.log.error('Error handling resource', { reason: error.body });
} else if (error instanceof Error) {
message = error.message;
this.#services.log.error('Error handling resource', { reason: error.message });
} else {
message = String(error);
this.#services.log.error('Error handling resource', { reason: String(error) });
}
if (type === 'ADDED' || type === 'MODIFIED') {
await request.setCondition({
type: 'Ready',
status: 'False',
reason: 'Error',
message,
});
}
}
};
#onError = (error: any) => {
console.error(error);
#onError = (error: ExpectedAny) => {
this.#services.log.error('Error watching resource', { error });
};
public install = async (replace = false) => {
const k8sService = this.#services.get(K8sService);
for (const crd of this.#resources) {
const manifest = crd.toManifest();
try {
await k8sService.extensionsApi.createCustomResourceDefinition({
body: manifest,
});
} catch (error) {
if (error instanceof ApiException && error.code === 409) {
if (replace) {
await k8sService.extensionsApi.patchCustomResourceDefinition({
name: crd.name,
body: [{ op: 'replace', path: '/spec', value: manifest.spec }],
});
const manifest = crd.toManifest();
try {
await k8sService.extensionsApi.createCustomResourceDefinition({
body: manifest,
});
} catch (error) {
if (error instanceof ApiException && error.code === 409) {
if (replace) {
await k8sService.extensionsApi.patchCustomResourceDefinition({
name: crd.name,
body: [{ op: 'replace', path: '/spec', value: manifest.spec }],
});
}
continue;
}
continue;
throw error;
}
} catch (error) {
if (error instanceof ApiException) {
throw new Error(`Failed to install ${crd.kind}: ${error.body}`);
}
throw error;
}

View File

@@ -1,15 +1,15 @@
import type { Static, TSchema } from '@sinclair/typebox';
import { ApiException, PatchStrategy, setHeaderOptions } from '@kubernetes/client-node';
import { Type, type Static, type TSchema } from '@sinclair/typebox';
import { ApiException, PatchStrategy, setHeaderOptions, V1MicroTime } from '@kubernetes/client-node';
import type { Services } from '../utils/service.ts';
import { K8sService } from '../services/k8s.ts';
import { GROUP } from '../utils/consts.ts';
import { CustomResourceRegistry } from './custom-resource.registry.ts';
import { CustomResourceStatus, type CustomResourceStatusType } from './custom-resource.status.ts';
type CustomResourceRequestOptions = {
type: 'ADDED' | 'DELETED' | 'MODIFIED';
manifest: any;
manifest: ExpectedAny;
services: Services;
};
@@ -24,6 +24,30 @@ type CustomResourceRequestMetadata = Record<string, string> & {
generation: number;
};
type CustomResourceEvent = {
reason: string;
message: string;
action: string;
type: 'Normal' | 'Warning' | 'Error';
};
const customResourceStatusSchema = Type.Object({
observedGeneration: Type.Number(),
conditions: Type.Array(
Type.Object({
type: Type.String(),
status: Type.String({
enum: ['True', 'False', 'Unknown'],
}),
lastTransitionTime: Type.String({ format: 'date-time' }),
reason: Type.Optional(Type.String()),
message: Type.Optional(Type.String()),
}),
),
});
type CustomResourceStatus = Static<typeof customResourceStatusSchema>;
class CustomResourceRequest<TSpec extends TSchema> {
#options: CustomResourceRequestOptions;
@@ -59,10 +83,10 @@ class CustomResourceRequest<TSpec extends TSchema> {
return this.#options.manifest.metadata;
}
public isOwnerOf = (manifest: any) => {
public isOwnerOf = (manifest: ExpectedAny) => {
const ownerRef = manifest?.metadata?.ownerReferences || [];
return ownerRef.some(
(ref: any) =>
(ref: ExpectedAny) =>
ref.apiVersion === this.apiVersion &&
ref.kind === this.kind &&
ref.name === this.metadata.name &&
@@ -70,11 +94,73 @@ class CustomResourceRequest<TSpec extends TSchema> {
);
};
public setStatus = async (status: CustomResourceStatusType) => {
public markSeen = async () => {
const { manifest } = this.#options;
await this.setStatus({
observedGeneration: manifest.metadata.generation,
});
};
public setCondition = async (condition: Omit<CustomResourceStatus['conditions'][number], 'lastTransitionTime'>) => {
const fullCondition = {
...condition,
lastTransitionTime: new Date().toISOString(),
};
const current = await this.getCurrent();
const conditions: CustomResourceStatus['conditions'] = current?.status?.conditions || [];
const index = conditions.findIndex((c) => c.type === condition.type);
if (index === -1) {
conditions.push(fullCondition);
} else {
conditions[index] = fullCondition;
}
await this.setStatus({
conditions,
});
};
public getStatus = async () => {
const current = await this.getCurrent();
return current?.status as CustomResourceStatus | undefined;
};
public addEvent = async (event: CustomResourceEvent) => {
const { manifest, services } = this.#options;
const k8sService = services.get(K8sService);
await k8sService.eventsApi.createNamespacedEvent({
namespace: manifest.metadata.namespace,
body: {
kind: 'Event',
metadata: {
name: `${manifest.metadata.name}-${Date.now()}`,
namespace: manifest.metadata.namespace,
},
eventTime: new V1MicroTime(),
note: event.message,
action: event.action,
reason: event.reason,
type: event.type,
reportingController: GROUP,
reportingInstance: manifest.metadata.name,
regarding: {
apiVersion: manifest.apiVersion,
resourceVersion: manifest.metadata.resourceVersion,
kind: manifest.kind,
name: manifest.metadata.name,
namespace: manifest.metadata.namespace,
uid: manifest.metadata.uid,
},
},
});
};
public setStatus = async (status: Partial<CustomResourceStatus>) => {
const { manifest, services } = this.#options;
const { kind, metadata } = manifest;
const registry = services.get(CustomResourceRegistry);
const crd = registry.getByKind(kind);
const current = await this.getCurrent();
if (!crd) {
throw new Error(`Custom resource ${kind} not found`);
}
@@ -90,7 +176,14 @@ class CustomResourceRequest<TSpec extends TSchema> {
namespace,
plural: crd.names.plural,
name,
body: { status },
body: {
status: {
observedGeneration: manifest.metadata.generation,
conditions: current?.status?.conditions || [],
...current?.status,
...status,
},
},
fieldValidation: 'Strict',
},
setHeaderOptions('Content-Type', PatchStrategy.MergePatch),
@@ -119,7 +212,7 @@ class CustomResourceRequest<TSpec extends TSchema> {
kind: string;
metadata: CustomResourceRequestMetadata;
spec: Static<TSpec>;
status: CustomResourceStatusType;
status: CustomResourceStatus;
};
} catch (error) {
if (error instanceof ApiException && error.code === 404) {
@@ -128,25 +221,6 @@ class CustomResourceRequest<TSpec extends TSchema> {
throw error;
}
};
public getStatus = async () => {
const resource = await this.getCurrent();
if (!resource || !resource.status) {
return new CustomResourceStatus({
status: {
observedGeneration: 0,
conditions: [],
},
generation: 0,
save: this.setStatus,
});
}
return new CustomResourceStatus({
status: { ...resource.status, observedGeneration: resource.status.observedGeneration },
generation: resource.metadata.generation,
save: this.setStatus,
});
};
}
export { CustomResourceRequest };
export { CustomResourceRequest, customResourceStatusSchema };

View File

@@ -1,85 +0,0 @@
import { Type, type Static } from '@sinclair/typebox';
type CustomResourceStatusType = Static<typeof statusSchema>;
const statusSchema = Type.Object({
observedGeneration: Type.Number(),
conditions: Type.Array(
Type.Object({
type: Type.String(),
status: Type.String({
enum: ['True', 'False', 'Unknown'],
}),
lastTransitionTime: Type.String(),
reason: Type.String(),
message: Type.String(),
}),
),
});
type CustomResourceStatusOptions = {
status?: CustomResourceStatusType;
generation: number;
save: (status: CustomResourceStatusType) => Promise<void>;
};
class CustomResourceStatus {
#status: CustomResourceStatusType;
#generation: number;
#save: (status: CustomResourceStatusType) => Promise<void>;
constructor(options: CustomResourceStatusOptions) {
this.#save = options.save;
this.#status = {
observedGeneration: options.status?.observedGeneration ?? 0,
conditions: options.status?.conditions ?? [],
};
this.#generation = options.generation;
}
public get generation() {
return this.#generation;
}
public get observedGeneration() {
return this.#status.observedGeneration;
}
public set observedGeneration(observedGeneration: number) {
this.#status.observedGeneration = observedGeneration;
}
public getCondition = (type: string) => {
return this.#status.conditions?.find((condition) => condition.type === type)?.status;
};
public setCondition = (
type: string,
condition: Omit<CustomResourceStatusType['conditions'][number], 'type' | 'lastTransitionTime'>,
) => {
const currentCondition = this.getCondition(type);
const newCondition = {
...condition,
type,
lastTransitionTime: new Date().toISOString(),
};
if (currentCondition) {
this.#status.conditions = this.#status.conditions.map((c) => (c.type === type ? newCondition : c));
} else {
this.#status.conditions.push(newCondition);
}
};
public save = async () => {
await this.#save({
...this.#status,
observedGeneration: this.#generation,
});
};
public toJSON = () => {
return this.#status;
};
}
export { CustomResourceStatus, statusSchema, type CustomResourceStatusType };

View File

@@ -1,11 +1,44 @@
import 'dotenv/config';
import { ApiException } from '@kubernetes/client-node';
import { CustomResourceRegistry } from './custom-resource/custom-resource.registry.ts';
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';
const services = new Services();
const registry = services.get(CustomResourceRegistry);
registry.register(new SecretRequest());
registry.register(new PostgresDatabase());
registry.register(new AuthentikClient());
await registry.install(true);
await registry.watch();
const authentikService = services.get(AuthentikService);
await authentikService.upsertClient({
name: 'foo',
secret: 'foo',
redirectUris: [{ url: 'http://localhost:3000/api/auth/callback', matchingMode: 'strict' }],
});
process.on('uncaughtException', (error) => {
console.log('UNCAUGHT EXCEPTION');
if (error instanceof ApiException) {
return console.error(error.body);
}
console.error(error);
});
process.on('unhandledRejection', (error) => {
console.log('UNHANDLED REJECTION');
if (error instanceof Error) {
// show stack trace
console.error(error.stack);
}
if (error instanceof ApiException) {
return console.error(error.body);
}
console.error(error);
});

View File

@@ -0,0 +1,216 @@
import type { Services } from '../../utils/service.ts';
import { ConfigService } from '../config/config.ts';
import { createAuthentikClient, type AuthentikClient } from '../../clients/authentik/authentik.ts';
import type { UpsertClientRequest, UpsertGroupRequest } from './authentik.types.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'];
class AuthentikService {
#client: AuthentikClient;
#services: Services;
constructor(services: Services) {
const config = services.get(ConfigService);
this.#client = createAuthentikClient({
baseUrl: new URL('api/v3', config.authentik.url).toString(),
token: config.authentik.token,
});
this.#services = services;
}
public get url() {
const config = this.#services.get(ConfigService);
return config.authentik.url;
}
#upsertApplication = async (request: UpsertClientRequest, provider: number, pk?: string) => {
if (!pk) {
return await this.#client.core.coreApplicationsCreate({
applicationRequest: {
name: request.name,
slug: request.name,
provider,
},
});
}
return await this.#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[];
if (!pk) {
return await this.#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 this.#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 groups = await this.#client.core.coreGroupsList({
search: name,
});
return groups.results.find((group) => group.name === name);
};
public getScopePropertyMappings = async () => {
const mappings = await this.#client.propertymappings.propertymappingsProviderScopeList({});
return mappings;
};
public getApplicationFromSlug = async (slug: string) => {
const applications = await this.#client.core.coreApplicationsList({
search: slug,
});
const application = applications.results.find((app) => app.slug === slug);
return application;
};
public getProviderFromClientId = async (clientId: string) => {
const providers = await this.#client.providers.providersOauth2List({
clientId,
});
return providers.results.find((provider) => provider.clientId === clientId);
};
public getFlows = async () => {
const flows = await this.#client.flows.flowsInstancesList();
return flows;
};
public upsertClient = async (request: UpsertClientRequest) => {
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`,
this.url,
).toString(),
configurationIssuer: new URL(`/application/o/${provider.name}/`, this.url).toString(),
authorization: new URL(`/application/o/${provider.name}/authorize/`, this.url).toString(),
token: new URL(`/application/o/${provider.name}/token/`, this.url).toString(),
userinfo: new URL(`/application/o/${provider.name}/userinfo/`, this.url).toString(),
endSession: new URL(`/application/o/${provider.name}/end-session/`, this.url).toString(),
jwks: new URL(`/application/o/${provider.name}/jwks/`, this.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);
if (provider) {
await this.#client.providers.providersOauth2Destroy({ id: provider.pk });
}
const application = await this.getApplicationFromSlug(name);
if (application) {
await this.#client.core.coreApplicationsDestroy({ slug: application.name });
}
};
public upsertGroup = async (request: UpsertGroupRequest) => {
const group = await this.getGroupFromName(request.name);
if (!group) {
await this.#client.core.coreGroupsCreate({
groupRequest: {
name: request.name,
attributes: request.attributes,
},
});
} else {
await this.#client.core.coreGroupsUpdate({
groupUuid: group.pk,
groupRequest: {
name: request.name,
attributes: request.attributes,
},
});
}
};
}
export { AuthentikService };

View File

@@ -0,0 +1,29 @@
import type { ClientTypeEnum, SubModeEnum } from '@goauthentik/api';
type UpsertClientRequest = {
name: string;
secret: string;
scopes?: string[];
flows?: {
authorization: string;
invalidation: string;
};
clientType?: ClientTypeEnum;
subMode?: SubModeEnum;
redirectUris: {
url: string;
matchingMode: 'strict' | 'regex';
}[];
timing?: {
accessCodeValidity?: string;
accessTokenValidity?: string;
refreshTokenValidity?: string;
};
};
type UpsertGroupRequest = {
name: string;
attributes?: Record<string, string[]>;
};
export type { UpsertClientRequest, UpsertGroupRequest };

View File

@@ -11,6 +11,17 @@ class ConfigService {
return { host, user, password, port };
}
public get authentik() {
const url = process.env.AUTHENTIK_URL;
const token = process.env.AUTHENTIK_TOKEN;
if (!url || !token) {
throw new Error('AUTHENTIK_URL and AUTHENTIK_TOKEN must be set');
}
return { url, token };
}
}
export { ConfigService };

View File

@@ -1,10 +1,19 @@
import { KubeConfig, CoreV1Api, ApiextensionsV1Api, CustomObjectsApi } from '@kubernetes/client-node';
import {
KubeConfig,
CoreV1Api,
ApiextensionsV1Api,
CustomObjectsApi,
EventsV1Api,
KubernetesObjectApi,
} from '@kubernetes/client-node';
class K8sService {
#kc: KubeConfig;
#k8sApi: CoreV1Api;
#k8sExtensionsApi: ApiextensionsV1Api;
#k8sCustomObjectsApi: CustomObjectsApi;
#k8sEventsApi: EventsV1Api;
#k8sObjectsApi: KubernetesObjectApi;
constructor() {
this.#kc = new KubeConfig();
@@ -12,6 +21,8 @@ class K8sService {
this.#k8sApi = this.#kc.makeApiClient(CoreV1Api);
this.#k8sExtensionsApi = this.#kc.makeApiClient(ApiextensionsV1Api);
this.#k8sCustomObjectsApi = this.#kc.makeApiClient(CustomObjectsApi);
this.#k8sEventsApi = this.#kc.makeApiClient(EventsV1Api);
this.#k8sObjectsApi = this.#kc.makeApiClient(KubernetesObjectApi);
}
public get config() {
@@ -29,6 +40,14 @@ class K8sService {
public get customObjectsApi() {
return this.#k8sCustomObjectsApi;
}
public get eventsApi() {
return this.#k8sEventsApi;
}
public get objectsApi() {
return this.#k8sObjectsApi;
}
}
export { K8sService };

9
src/utils/schemas.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { Static, TSchema } from '@sinclair/typebox';
import { TypeCompiler } from '@sinclair/typebox/compiler';
const isSchemaValid = <T extends TSchema>(schema: T, data: unknown): data is Static<T> => {
const compiler = TypeCompiler.Compile(schema);
return compiler.Check(data);
};
export { isSchemaValid };

6
src/utils/types.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
declare global {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ExpectedAny = any;
}
export {};