-
Notifications
You must be signed in to change notification settings - Fork 6
Deploy Keycloak as an OAuth provider #443
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 16 commits
eaab062
fb59dd8
45aac27
cb867ca
614a94e
8d0668b
0d0ea83
127c692
f73f3b6
da8c668
dd65a24
4205c43
101fe91
7200867
acdc62a
ac7c368
4f6daea
b35cf42
9a80e55
284b8d9
15ed6a7
e935fbb
e0b480f
3493233
e51b307
47fab3e
b4e4963
98af79f
94e7ed0
e83d828
fcb2dc1
d075eee
4f97679
d9a6c76
688292b
fea8727
8c20987
08a3193
f17aa0e
edb12fc
7fa281d
5189e03
c4a33d2
9d44e3d
cf237dc
b7e7294
28b296d
c177e65
ae0290b
c77c634
62c36cd
01a0585
628e344
da8e5d9
fbfbdfc
1461db3
2034cae
1d6aea2
b063321
e263eac
847a97b
a379116
b4f4da2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,247 @@ | ||
| import crypto from "crypto"; | ||
| import fs from "fs/promises"; | ||
| import path from "path"; | ||
| import { URLSearchParams } from "url"; | ||
|
|
||
| /** | ||
| * Create the startup OpenID realm `factory_plus`. | ||
| * | ||
| * @async | ||
| * @param {ServiceSetup} service_setup - Contains the configuration for setting up the realm. | ||
| * @returns {Promise<void>} Resolves when the realm creation process finishes. | ||
| */ | ||
| export async function create_openid_realm(service_setup) { | ||
| await new RealmSetup(service_setup).run(); | ||
| } | ||
|
|
||
| class RealmSetup { | ||
| constructor(service_setup) { | ||
| const { fplus } = service_setup; | ||
| this.log = fplus.debug.bound("oauth"); | ||
|
|
||
| this.username = fplus.opts.username; | ||
| this.password = fplus.opts.password; | ||
| this.realm = "factory_plus"; | ||
| this.base_url = service_setup.acs_config.domain; | ||
| this.secure = service_setup.acs_config.secure; | ||
| this.access_token = ""; | ||
| this.refresh_token = ""; | ||
| this.config = service_setup.config; | ||
| } | ||
|
|
||
| /** | ||
| * Run setup for the realm. This generates the full realm representation. | ||
| * | ||
| * A basic realm is created first and then populated with clients. | ||
| * | ||
| * @async | ||
| * @returns {Promise<void>} | ||
| */ | ||
| async run() { | ||
| let base_realm = { | ||
| id: crypto.randomUUID(), | ||
| realm: this.realm, | ||
| displayName: "Factory+", | ||
| displayNameHtml: '<div class="kc-logo-text"><span>Factory+</span></div>', | ||
| enabled: true, | ||
| components: { | ||
| "org.keycloak.storage.UserStorageProvider": [ | ||
| { | ||
| id: crypto.randomUUID(), | ||
| name: "Kerberos", | ||
| providerId: "kerberos", | ||
| subComponents: {}, | ||
| config: { | ||
| serverPrincipal: [ | ||
| `HTTP/openid.${this.base_url}@${this.base_url.toUpperCase()}`, | ||
KavanPrice marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| ], | ||
| allowPasswordAuthentication: ["false"], | ||
| debug: ["true"], | ||
| keyTab: ["/etc/keytabs/server"], | ||
| cachePolicy: ["DEFAULT"], | ||
| updateProfileFirstLogin: ["false"], | ||
| kerberosRealm: [this.base_url.toUpperCase()], | ||
| enabled: ["true"], | ||
| }, | ||
| }, | ||
| ], | ||
| }, | ||
| }; | ||
|
|
||
| await this.get_initial_access_token(); | ||
| await this.create_basic_realm(base_realm, false); | ||
|
|
||
| const client_configs = Object.values(this.config.openidClients); | ||
| const enabled_clients = client_configs.filter( | ||
| (client) => client.enabled === true, | ||
| ); | ||
| for (const client of enabled_clients) { | ||
| await this.create_client(client, false); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Create a new realm by POSTing to the OpenID service. Throws if the response is not ok. | ||
| * | ||
| * @async | ||
| * @param {Object} realm_representation - An object containing all the values that should be created for the realm. | ||
| * @param {Boolean} is_retry - Whether the request is a retry after a 401. If this is false and a 401 is returned, this will retry after refreshing the token. | ||
| * @returns {Promise<void>} Resolves when the realm is created. | ||
| */ | ||
| async create_basic_realm(realm_representation, is_retry) { | ||
| const realm_url = `http${this.secure}://openid.${this.base_url}/admin/realms`; | ||
|
|
||
| this.log(`Attempting basic realm creation at: ${realm_url}`); | ||
|
|
||
| try { | ||
| const response = await fetch(realm_url, { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| Authorization: `Bearer ${this.access_token}`, | ||
| }, | ||
| body: JSON.stringify(realm_representation), | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| const status = response.status; | ||
|
|
||
| if (status == 401 && !is_retry) { | ||
| await this.get_initial_access_token(this.refresh_token); | ||
| await this.create_basic_realm(realm_representation, true); | ||
| } else if (status == 503) { | ||
| await this.wait(10000); | ||
| await this.create_basic_realm(realm_representation, false); | ||
| } else { | ||
| const error = await response.text(); | ||
| throw new Error(`${status}: ${error}`); | ||
| } | ||
| } | ||
| } catch (error) { | ||
| this.log(`Couldn't setup realm: ${error}`); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Create a new client by POSTing to the OpenID service. Throws if the response is not ok. | ||
| * | ||
| * @async | ||
| * @param {Object} client_representation - An object containing all the values that should be created for the client. | ||
| * @param {Boolean} is_retry - Whether the request is a retry after a 401. If this is false and a 401 is returned, this will retry after refreshing the token. | ||
| * @returns {Promise<void>} Resolves when the client is created. | ||
| */ | ||
| async create_client(client_representation, is_retry) { | ||
| const secret_path = path.join( | ||
| "/etc/secret", | ||
| client_representation.redirectHost, | ||
| ); | ||
| const content = await fs.readFile(secret_path, "utf8"); | ||
| const client_secret = content.trim(); | ||
|
||
|
|
||
| const client = { | ||
| id: crypto.randomUUID(), | ||
| clientId: client_representation.clientId, | ||
| name: client_representation.name, | ||
| rootUrl: `http${this.secure}://${client_representation.redirectHost}.${this.base_url}`, | ||
| adminUrl: `http${this.secure}://${client_representation.redirectHost}.${this.base_url}`, | ||
| baseUrl: `http${this.secure}://${client_representation.redirectHost}.${this.base_url}`, | ||
| enabled: true, | ||
| secret: client_secret, | ||
| redirectUris: [ | ||
| `http${this.secure}://${client_representation.redirectHost}.${this.base_url}${client_representation.redirectPath}`, | ||
| ], | ||
| }; | ||
|
|
||
| const client_url = `http${this.secure}://openid.${this.base_url}/admin/realms/${this.realm}/clients`; | ||
|
|
||
| this.log(`Attempting client creation at: ${client_url}`); | ||
|
|
||
| try { | ||
| const response = await fetch(client_url, { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| Authorization: `Bearer ${this.access_token}`, | ||
| }, | ||
| body: JSON.stringify(client), | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| const status = response.status; | ||
|
|
||
| if (status == 401 && !is_retry) { | ||
| await this.get_initial_access_token(this.refresh_token); | ||
| await this.create_client(client_representation, true); | ||
| } else if (status == 503) { | ||
| await this.wait(10000); | ||
| await this.create_client(client_representation, false); | ||
| } else { | ||
| const error = await response.text(); | ||
| throw new Error(`${status}: ${error}`); | ||
| } | ||
| } | ||
| } catch (error) { | ||
| this.log(`Couldn't setup client: ${error}`); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Fetch an initial access token for the user in the specified realm. This should only be used during realm setup. | ||
| * | ||
| * Sets the `access_token` and `refresh_token` properties of `OAuthRealm` as a side effect. | ||
| * | ||
| * @async | ||
| * @param {string | undefined} [refresh_token] - Optional refresh token. If this is passed we use it with bearer auth. | ||
| * @returns {Promise<[string, string]} Resolves to an access token and a refresh token. | ||
| */ | ||
| async get_initial_access_token(refresh_token) { | ||
| const token_url = `http${this.secure}://openid.${this.base_url}/realms/master/protocol/openid-connect/token`; | ||
|
|
||
| this.log(`Attempting token request at: ${token_url}`); | ||
|
|
||
| const params = new URLSearchParams(); | ||
| if (refresh_token != undefined) { | ||
| params.append("grant_type", "refresh_token"); | ||
| params.append("client_id", "admin-cli"); | ||
| params.append("refresh_token", refresh_token); | ||
| } else { | ||
| params.append("grant_type", "password"); | ||
| params.append("client_id", "admin-cli"); | ||
| params.append("username", this.username); | ||
| params.append("password", this.password); | ||
| } | ||
|
|
||
| try { | ||
| const response = await fetch(token_url, { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/x-www-form-urlencoded", | ||
| }, | ||
| body: params, | ||
| }); | ||
|
|
||
| if (response.ok) { | ||
| const data = await response.json(); | ||
| this.access_token = data.access_token; | ||
| this.refresh_token = data.refresh_token; | ||
|
|
||
| return [data.access_token, data.refresh_token]; | ||
| } else if (response.status == 503) { | ||
| await this.wait(10000); | ||
| this.get_initial_access_token(); | ||
| } else { | ||
| const status = response.status; | ||
| const error = await response.text(); | ||
| throw new Error(`${status}: ${error}`); | ||
| } | ||
| } catch (error) { | ||
| this.log( | ||
| `Couldn't get an initial access token for realm setup: ${error}`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| async wait(milliseconds) { | ||
KavanPrice marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| return new Promise((resolve) => setTimeout(resolve, milliseconds)); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| apiVersion: apiextensions.k8s.io/v1 | ||
| kind: CustomResourceDefinition | ||
| metadata: | ||
| name: localsecrets.factoryplus.app.amrc.co.uk | ||
| spec: | ||
| group: factoryplus.app.amrc.co.uk | ||
| names: | ||
| kind: LocalSecret | ||
| plural: localsecrets | ||
| categories: | ||
| - all | ||
| scope: Namespaced | ||
| versions: | ||
| - name: v1 | ||
| served: true | ||
| storage: true | ||
| additionalPrinterColumns: | ||
| - name: Secret | ||
| jsonPath: ".spec.secret" | ||
| type: string | ||
| - name: Key | ||
| jsonPath: ".spec.key" | ||
| type: string | ||
| - name: Format | ||
| jsonPath: ".spec.format" | ||
| type: string | ||
| schema: | ||
| openAPIV3Schema: | ||
| type: object | ||
| required: [spec] | ||
| properties: | ||
| spec: | ||
| type: object | ||
| required: [secret, key, format] | ||
| properties: | ||
| secret: | ||
| description: The name of the Secret to edit. | ||
| type: string | ||
| key: | ||
| description: The key to create within the Secret. | ||
| type: string | ||
| format: | ||
| description: > | ||
| The format of the secret value. Currently must be Password. | ||
| type: string | ||
| enum: [Password] | ||
| status: | ||
| type: object | ||
| x-kubernetes-preserve-unknown-fields: true | ||
| subresources: | ||
| status: {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| apiVersion: v1 | ||
| kind: ConfigMap | ||
| metadata: | ||
| namespace: {{ .Release.Namespace }} | ||
| name: acs-grafana-config | ||
| data: | ||
| auth_url: {{ .Values.acs.secure | ternary "https://" "http://" }}openid.{{.Values.acs.baseUrl}}/realms/factory_plus/protocol/openid-connect/auth | ||
| token_url: {{ .Values.acs.secure | ternary "https://" "http://" }}openid.{{.Values.acs.baseUrl}}/realms/factory_plus/protocol/openid-connect/token | ||
| api_url: {{ .Values.acs.secure | ternary "https://" "http://" }}openid.{{.Values.acs.baseUrl}}/realms/factory_plus/protocol/openid-connect/userinfo |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| {{if .Values.openid.enabled}} | ||
| apiVersion: factoryplus.app.amrc.co.uk/v1 | ||
| kind: LocalSecret | ||
| metadata: | ||
| namespace: {{ .Release.Namespace }} | ||
| name: "keycloak-grafana-client" | ||
| spec: | ||
| format: Password | ||
| secret: "keycloak-grafana-client" | ||
| key: "grafana" | ||
KavanPrice marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| {{end }} | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| {{ if .Values.openid.enabled }} | ||
| apiVersion: traefik.io/v1alpha1 | ||
| kind: IngressRoute | ||
| metadata: | ||
| name: openid-ingressroute | ||
| namespace: {{ .Release.Namespace }} | ||
| spec: | ||
| entryPoints: | ||
| - {{ .Values.acs.secure | ternary "websecure" "web" }} | ||
| routes: | ||
| - match: Host(`openid.{{.Values.acs.baseUrl | required "values.acs.baseUrl is required"}}`) | ||
| kind: Rule | ||
| services: | ||
| - name: openid | ||
| port: 80 | ||
| namespace: {{ .Release.Namespace }} | ||
| {{- if .Values.acs.secure }} | ||
| tls: | ||
| secretName: {{ coalesce .Values.openid.tlsSecretName .Values.acs.tlsSecretName }} | ||
| domains: | ||
| - main: openid.{{.Values.acs.baseUrl | required "values.acs.baseUrl is required"}} | ||
| {{- end -}} | ||
| {{- end -}} |
Uh oh!
There was an error while loading. Please reload this page.