diff --git a/Tiltfile b/Tiltfile index 10c49aa9..089b203b 100644 --- a/Tiltfile +++ b/Tiltfile @@ -18,6 +18,10 @@ load('ext://helm_resource', 'helm_resource', 'helm_repo') load("ext://restart_process", "docker_build_with_restart") load("ext://secret", "secret_create_generic") +load('ext://dotenv', 'dotenv') + +# Load .env file if it exists +dotenv() config.define_string("aws_role", usage='AWS role to use for deployment') cfg = config.parse(); @@ -33,6 +37,13 @@ if aws_role: lifecycle_app = 'lifecycle-app' app_namespace = 'lifecycle-app' +# NGROK Configuration +ngrok_authtoken = os.getenv("NGROK_AUTHTOKEN", "") +ngrok_domain = os.getenv("NGROK_LIFECYCLE_DOMAIN", "") +ngrok_keycloak_domain = os.getenv("NGROK_KEYCLOAK_DOMAIN", "") +ngrok_ui_domain = os.getenv("NGROK_LIFECYCLE_UI_DOMAIN", "") + + ################################## # Create Namespace ################################## @@ -118,16 +129,27 @@ docker_build_with_restart( ], ) +helm_set_args = [ + 'namespace={}'.format(app_namespace), + 'image.repository={}'.format(lifecycle_app), + 'image.tag=dev', + 'keycloak.url={}'.format(ngrok_keycloak_domain or 'localhost'), + 'keycloak.appUrl={}'.format(ngrok_domain or 'localhost:5001'), + 'keycloak.uiUrl={}'.format(ngrok_ui_domain or 'localhost:3000'), + # Update IDP URLs to use ngrok domain or localhost + 'keycloak.companyIdp.tokenUrl=https://{}/realms/company/protocol/openid-connect/token'.format(ngrok_keycloak_domain) if ngrok_keycloak_domain else 'keycloak.companyIdp.tokenUrl=http://localhost:8080/realms/company/protocol/openid-connect/token', + 'keycloak.companyIdp.authorizationUrl=https://{}/realms/company/protocol/openid-connect/auth'.format(ngrok_keycloak_domain) if ngrok_keycloak_domain else 'keycloak.companyIdp.authorizationUrl=http://localhost:8080/realms/company/protocol/openid-connect/auth', + 'keycloak.companyIdp.userInfoUrl=https://{}/realms/company/protocol/openid-connect/userinfo'.format(ngrok_keycloak_domain) if ngrok_keycloak_domain else 'keycloak.companyIdp.userInfoUrl=http://localhost:8080/realms/company/protocol/openid-connect/userinfo', + 'keycloak.companyIdp.jwksUrl=https://{}/realms/company/protocol/openid-connect/certs'.format(ngrok_keycloak_domain) if ngrok_keycloak_domain else 'keycloak.companyIdp.jwksUrl=http://localhost:8080/realms/company/protocol/openid-connect/certs', + 'keycloak.companyIdp.issuer=https://{}/realms/company'.format(ngrok_keycloak_domain) if ngrok_keycloak_domain else 'keycloak.companyIdp.issuer=http://localhost:8080/realms/company', +] + lifecycle_deployment = decode_yaml_stream(helm( './helm/web-app/', name='lifecycle', namespace=app_namespace, values=['./helm/environments/local/lifecycle.yaml', './helm/environments/local/secrets.yaml'], - set=[ - 'namespace={}'.format(app_namespace), - 'image.repository={}'.format(lifecycle_app), - 'image.tag=dev', - ] + set=helm_set_args )) patched_deploy = [] @@ -160,6 +182,11 @@ for r in patched_deploy: name = r["metadata"]["name"] labels = [] port_forwards = [] + resource_deps = [] + + # Don't add postgres/redis deps for keycloak resources + if "keycloak" not in name: + resource_deps = ['local-postgres', 'redis'] if "web" in name: labels = ["web"] port_forwards = ['5001:80'] @@ -167,7 +194,7 @@ for r in patched_deploy: labels = ["worker"] k8s_resource( name, - resource_deps=['local-postgres', 'redis'], + resource_deps=resource_deps, labels=labels, port_forwards=port_forwards ) @@ -175,8 +202,6 @@ for r in patched_deploy: ################################## # NGROK ################################## -ngrok_authtoken = os.getenv("NGROK_AUTHTOKEN", "") -ngrok_domain = os.getenv("NGROK_LIFECYCLE_DOMAIN", "") ngrok_secret_yaml = """ apiVersion: v1 @@ -188,10 +213,13 @@ type: Opaque stringData: NGROK_AUTHTOKEN: "{}" NGROK_LIFECYCLE_DOMAIN: "{}" -""".format(app_namespace, ngrok_authtoken, ngrok_domain) + NGROK_KEYCLOAK_DOMAIN: "{}" +""".format(app_namespace, ngrok_authtoken, ngrok_domain, ngrok_keycloak_domain) ngrok_secret_obj = decode_yaml_stream(ngrok_secret_yaml) k8s_yaml(encode_yaml_stream(ngrok_secret_obj)) + +# Main app ngrok k8s_yaml('sysops/tilt/ngrok.yaml') k8s_resource( 'ngrok', @@ -199,6 +227,30 @@ k8s_resource( labels=["infra"] ) +# Ngrok for Keycloak +k8s_yaml('sysops/tilt/ngrok-keycloak.yaml') +k8s_resource( + 'ngrok-keycloak', + port_forwards=['4041:4040'], # Different local port for Keycloak ngrok admin + labels=["infra"] +) + +################################## +# Keycloak (deployed via Helm) +################################## +# Keycloak is deployed as part of the lifecycle helm release +# We just need to configure the resources for Tilt UI +k8s_resource( + 'lifecycle-keycloak', + port_forwards=['8081:8080'], + labels=["infra"], + resource_deps=['lifecycle-keycloak-postgresql'] +) +k8s_resource( + 'lifecycle-keycloak-postgresql', + labels=["infra"] +) + ################################## # DISTRIBUTION ################################## diff --git a/helm/environments/local/lifecycle.yaml b/helm/environments/local/lifecycle.yaml index d4cbd170..efcf00e4 100644 --- a/helm/environments/local/lifecycle.yaml +++ b/helm/environments/local/lifecycle.yaml @@ -150,3 +150,68 @@ redis: rbac: create: true + +keycloak: + enabled: true + realm: lifecycle + url: localhost + appUrl: localhost:5001 + + adminUsername: admin + adminPassword: admin + defaultUserPassword: changeme + + postgresUsername: keycloakdb + postgresPassword: keycloakdb + + lifecycleCoreClientSecret: changeme + + # Identity Provider configurations (disabled for local dev, enable as needed) + companyIdp: + enabled: true + clientId: app-broker + clientSecret: changeme + tokenUrl: http://localhost:8080/realms/company/protocol/openid-connect/token + authorizationUrl: http://localhost:8080/realms/company/protocol/openid-connect/auth + userInfoUrl: http://localhost:8080/realms/company/protocol/openid-connect/userinfo + jwksUrl: http://localhost:8080/realms/company/protocol/openid-connect/certs + issuer: http://localhost:8080/realms/company + + githubIdp: + enabled: true + # clientId and clientSecret will be taken from secrets.githubClientId and secrets.githubClientSecret + # clientId + # clientSecret + + image: + registry: quay.io + repository: keycloak/keycloak + tag: 26.3.4 + + resources: + requests: + cpu: 400m + memory: 512Mi + limits: + memory: 768Mi + + postgresImage: + registry: '' + repository: library/postgres + tag: 16.3-alpine + + postgresResources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi + + persistent: true + volumeSize: 1Gi + + ingress: + enabled: false + className: nginx + tls: false diff --git a/helm/web-app/templates/keycloak-configmap.yaml b/helm/web-app/templates/keycloak-configmap.yaml new file mode 100644 index 00000000..10a73155 --- /dev/null +++ b/helm/web-app/templates/keycloak-configmap.yaml @@ -0,0 +1,642 @@ +{{- /* +Copyright 2025 GoodRx, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/}} + +{{- if .Values.keycloak.enabled }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }}-keycloak-config + namespace: {{ .Release.Namespace }} + labels: + app: {{ .Release.Name }} + component: keycloak +data: + lifecycle-realm-config.json: |- + { + "realm": "lifecycle", + "displayName": "Lifecycle", + "displayNameHtml": "Lifecycle", + "enabled": true, + "users": [ + { + "username": "service-account-lifecycle-ui-backend", + "emailVerified": false, + "enabled": true, + "createdTimestamp": 1758816553655, + "totp": false, + "serviceAccountClientId": "lifecycle-ui-backend", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": ["default-roles-lifecycle"], + "clientRoles": { + "realm-management": ["view-users", "query-users"] + }, + "notBefore": 0, + "groups": [] + } + ], + "clients": [ + { + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "true" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "organization", + "microprofile-jwt" + ] + }, + { + "clientId": "lifecycle-core", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "{{ .Values.keycloak.lifecycleCoreClientSecret | default "changeme" }}", + "redirectUris": ["/*"], + "webOrigins": ["+"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "client.secret.creation.time": "1758832973", + "request.object.signature.alg": "any", + "request.object.encryption.alg": "any", + "client.introspection.response.allow.jwt.claim.enabled": "false", + "standard.token.exchange.enabled": "false", + "frontchannel.logout.session.required": "true", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "use.refresh.tokens": "true", + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "client.use.lightweight.access.token.enabled": "false", + "backchannel.logout.session.required": "true", + "request.object.required": "not required", + "client_credentials.use_refresh_token": "false", + "access.token.header.type.rfc9068": "false", + "tls.client.certificate.bound.access.tokens": "false", + "require.pushed.authorization.requests": "false", + "acr.loa.map": "{}", + "display.on.consent.screen": "false", + "request.object.encryption.enc": "any", + "token.response.type.bearer.lower-case": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "organization", + "microprofile-jwt" + ] + }, + { + "clientId": "lifecycle-ui", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "changeme", + "redirectUris": ["https://{{ .Values.keycloak.uiUrl }}/*"], + "webOrigins": ["+"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "backchannel.logout.session.required": "true", + "standard.token.exchange.enabled": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "name": "Lifecycle core aud", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "lifecycle-core", + "id.token.claim": "false", + "lightweight.claim": "false", + "access.token.claim": "true", + "introspection.token.claim": "true", + "userinfo.token.claim": "false" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "organization", + "microprofile-jwt" + ] + }, + { + "clientId": "lifecycle-ui-backend", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "changeme", + "redirectUris": ["/*"], + "webOrigins": ["/*"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "client.secret.creation.time": "1758816553", + "backchannel.logout.session.required": "true", + "standard.token.exchange.enabled": "false", + "frontchannel.logout.session.required": "true", + "oauth2.device.authorization.grant.enabled": "false", + "display.on.consent.screen": "false", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "service_account", + "acr", + "roles", + "profile", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "organization", + "microprofile-jwt" + ] + } + ], + "identityProviders": [ + { + "alias": "github", + "displayName": "", + "internalId": "40d9df17-3fe5-4dc4-a229-d0a8aa0104d9", + "providerId": "github", + "enabled": true, + "updateProfileFirstLoginMode": "on", + "trustEmail": false, + "storeToken": true, + "addReadTokenRoleOnCreate": false, + "authenticateByDefault": false, + "linkOnly": true, + "hideOnLogin": false, + "config": { + "clientId": "{{ .Values.keycloak.githubIdp.clientId | default .Values.secrets.githubClientId }}", + "clientSecret": "{{ .Values.keycloak.githubIdp.clientSecret | default .Values.secrets.githubClientSecret }}", + "acceptsPromptNoneForwardFromClient": "false", + "disableUserInfo": "false", + "syncMode": "LEGACY", + "filteredByClaim": "false", + "caseSensitiveOriginalUsername": "false" + } + }, + { + "alias": "company-sso", + "displayName": "", + "internalId": "5a66993a-a829-4e85-8e47-cff1cdab07b0", + "providerId": "oidc", + "enabled": true, + "updateProfileFirstLoginMode": "on", + "trustEmail": true, + "storeToken": false, + "addReadTokenRoleOnCreate": false, + "authenticateByDefault": false, + "linkOnly": false, + "hideOnLogin": false, + "config": { + "tokenUrl": "{{ .Values.keycloak.companyIdp.tokenUrl }}", + "acceptsPromptNoneForwardFromClient": "false", + "jwksUrl": "{{ .Values.keycloak.companyIdp.jwksUrl }}", + "isAccessTokenJWT": "false", + "filteredByClaim": "false", + "backchannelSupported": "false", + "caseSensitiveOriginalUsername": "false", + "loginHint": "false", + "clientAuthMethod": "client_secret_post", + "syncMode": "LEGACY", + "clientSecret": "changeme-company-realm-broker-secret", + "requiresShortStateParameter": "false", + "allowedClockSkew": "0", + "defaultScope": "openid profile email", + "validateSignature": "true", + "clientId": "main-broker", + "uiLocales": "false", + "disableNonce": "false", + "useJwksUrl": "true", + "sendClientIdOnLogout": "false", + "pkceEnabled": "false", + "authorizationUrl": "{{ .Values.keycloak.companyIdp.authorizationUrl }}", + "disableUserInfo": "false", + "sendIdTokenOnLogout": "true", + "passMaxAge": "false", + "disableTypeClaimCheck": "false" + } + } + ], + "components": { + "org.keycloak.userprofile.UserProfileProvider": [ + { + "providerId": "declarative-user-profile", + "subComponents": {}, + "config": { + "kc.user.profile.config": [ + "{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{}},\"annotations\":{},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"annotations\":{},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"firstName\",\"displayName\":\"${firstName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"annotations\":{},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"lastName\",\"displayName\":\"${lastName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"annotations\":{},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\"]},\"multivalued\":false}],\"groups\":[{\"name\":\"user-metadata\",\"displayHeader\":\"User metadata\",\"displayDescription\":\"Attributes, which refer to user metadata\"}]}" + ] + } + } + ] + }, + "authenticationFlows": [ + { + "alias": "browser - idp only", + "description": "Browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": false, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorConfig": "company-sso", + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 25, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 26, + "autheticatorFlow": true, + "flowAlias": "browser - idp only Organization", + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "DISABLED", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "browser - idp only forms", + "userSetupAllowed": false + } + ] + }, + { + "alias": "browser - idp only Browser - Conditional 2FA", + "description": "Flow to determine if any 2FA is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": false, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "webauthn-authenticator", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-recovery-authn-code-form", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "alias": "browser - idp only Browser - Conditional Organization", + "description": "Flow to determine if the organization identity-first login is to be used", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": false, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "organization", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "alias": "browser - idp only Organization", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": false, + "authenticationExecutions": [ + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "browser - idp only Browser - Conditional Organization", + "userSetupAllowed": false + } + ] + }, + { + "alias": "browser - idp only forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": false, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "browser - idp only Browser - Conditional 2FA", + "userSetupAllowed": false + } + ] + } + ], + "authenticatorConfig": [ + { + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "alias": "company-sso", + "config": { + "defaultProvider": "company-sso" + } + }, + { + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": false, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": false, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "idp_link", + "name": "Linking Identity Provider", + "providerId": "idp_link", + "enabled": true, + "defaultAction": false, + "priority": 110, + "config": {} + } + ], + "browserFlow": "browser - idp only", + "keycloakVersion": "26.3.4" + } + company-realm-config.json: |- + { + "realm": "company", + "enabled": true, + "displayName": "Company", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "rememberMe": false, + "verifyEmail": false, + "sslRequired": "external", + "users": [ + { + "username": "lifecycle", + "enabled": true, + "email": "lifecycle@example.com", + "emailVerified": true, + "firstName": "Lifecycle", + "lastName": "User", + "credentials": [ + { + "type": "password", + "value": "lifecycle", + "temporary": false + } + ] + } + ], + + "clients": [ + { + "clientId": "main-broker", + "name": "Main Realm Broker Client", + "description": "OIDC client used by the main realm to broker logins.", + "protocol": "openid-connect", + "publicClient": false, + "secret": "changeme-company-realm-broker-secret", + "redirectUris": [ + "https://{{ .Values.keycloak.url }}/realms/lifecycle/broker/company-sso/endpoint/*" + ], + "webOrigins": ["+"], + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "authorizationServicesEnabled": false, + "rootUrl": "", + "baseUrl": "", + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "organization", + "offline_access", + "microprofile-jwt" + ] + } + ] + } +{{- end }} \ No newline at end of file diff --git a/helm/web-app/templates/keycloak-deployment.yaml b/helm/web-app/templates/keycloak-deployment.yaml new file mode 100644 index 00000000..f8489027 --- /dev/null +++ b/helm/web-app/templates/keycloak-deployment.yaml @@ -0,0 +1,116 @@ +{{- /* +Copyright 2025 GoodRx, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/}} + +{{- if .Values.keycloak.enabled }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-keycloak + namespace: {{ .Release.Namespace }} + labels: + app: {{ .Release.Name }} + component: keycloak +spec: + strategy: + type: Recreate + replicas: 1 + selector: + matchLabels: + app: {{ .Release.Name }} + component: keycloak + template: + metadata: + labels: + app: {{ .Release.Name }} + component: keycloak + spec: + containers: + - name: keycloak-server + image: "{{ .Values.keycloak.image.registry }}/{{ .Values.keycloak.image.repository }}:{{ .Values.keycloak.image.tag }}" + ports: + - name: http + containerPort: 8080 + protocol: TCP + - name: management + containerPort: 9000 + protocol: TCP + args: + - 'start' + - '--features=token-exchange' + - '--db=postgres' + - '--db-url-host={{ .Release.Name }}-keycloak-postgresql' + - '--db-username=$(KC_DB_USER)' + - '--db-password=$(KC_DB_PASSWORD)' + - '--hostname=https://{{ .Values.keycloak.url }}' + - '--hostname-admin=https://{{ .Values.keycloak.url }}' + - '--hostname-strict=false' + - '--health-enabled=true' + - '--import-realm' + - '--proxy=edge' + - '--proxy-headers=xforwarded' + - '--http-enabled=true' + env: + - name: KEYCLOAK_ADMIN + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-keycloak-admin + key: username + - name: KEYCLOAK_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-keycloak-admin + key: password + - name: KC_DB_USER + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-keycloak-admin + key: postgresUsername + - name: KC_DB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-keycloak-admin + key: postgresPassword + - name: KC_DB_URL_DATABASE + value: keycloak + livenessProbe: + httpGet: + path: "/health/live" + port: 9000 + scheme: HTTP + initialDelaySeconds: 60 + timeoutSeconds: 5 + periodSeconds: 20 + failureThreshold: 3 + readinessProbe: + httpGet: + path: "/health/ready" + port: 9000 + scheme: HTTP + initialDelaySeconds: 30 + timeoutSeconds: 5 + periodSeconds: 10 + failureThreshold: 3 + volumeMounts: + - name: keycloak-config + mountPath: "/opt/keycloak/data/import" + resources: + {{- toYaml .Values.keycloak.resources | nindent 10 }} + volumes: + - name: keycloak-config + configMap: + name: {{ .Release.Name }}-keycloak-config +{{- end }} \ No newline at end of file diff --git a/helm/web-app/templates/keycloak-ingress.yaml b/helm/web-app/templates/keycloak-ingress.yaml new file mode 100644 index 00000000..ec299691 --- /dev/null +++ b/helm/web-app/templates/keycloak-ingress.yaml @@ -0,0 +1,58 @@ +{{- /* +Copyright 2025 GoodRx, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/}} + +{{- if .Values.keycloak.enabled }} +{{- if .Values.keycloak.ingress.enabled }} +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ .Release.Name }}-keycloak + namespace: {{ .Release.Namespace }} + labels: + app: {{ .Release.Name }} + component: keycloak + annotations: + {{- with .Values.keycloak.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.keycloak.ingress.className }} + ingressClassName: {{ .Values.keycloak.ingress.className | quote }} + {{- end }} + {{- if .Values.keycloak.ingress.tls }} + tls: + - hosts: + - {{ .Values.keycloak.url }} + {{- if .Values.keycloak.ingress.tlsSecretName }} + secretName: {{ .Values.keycloak.ingress.tlsSecretName }} + {{- else }} + secretName: {{ .Release.Name }}-keycloak-tls + {{- end }} + {{- end }} + rules: + - host: {{ .Values.keycloak.url }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ .Release.Name }}-keycloak + port: + number: 8080 +{{- end }} +{{- end }} \ No newline at end of file diff --git a/helm/web-app/templates/keycloak-postgres-deployment.yaml b/helm/web-app/templates/keycloak-postgres-deployment.yaml new file mode 100644 index 00000000..8b972e2b --- /dev/null +++ b/helm/web-app/templates/keycloak-postgres-deployment.yaml @@ -0,0 +1,97 @@ +{{- /* +Copyright 2025 GoodRx, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/}} + +{{- if .Values.keycloak.enabled }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-keycloak-postgresql + namespace: {{ .Release.Namespace }} + labels: + app: {{ .Release.Name }} + component: keycloak-postgresql +spec: + strategy: + type: Recreate + replicas: 1 + selector: + matchLabels: + app: {{ .Release.Name }} + component: keycloak-postgresql + template: + metadata: + labels: + app: {{ .Release.Name }} + component: keycloak-postgresql + spec: + containers: + - name: keycloak-postgresql + image: "{{ .Values.keycloak.postgresImage.registry }}{{ .Values.keycloak.postgresImage.repository }}:{{ .Values.keycloak.postgresImage.tag }}" + args: ["-c", "max_connections=100", "-c", "shared_buffers=12MB"] + ports: + - name: postgres + containerPort: 5432 + protocol: TCP + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-keycloak-admin + key: postgresUsername + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-keycloak-admin + key: postgresPassword + - name: POSTGRES_DB + value: keycloak + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + livenessProbe: + tcpSocket: + port: 5432 + initialDelaySeconds: 30 + timeoutSeconds: 1 + periodSeconds: 10 + failureThreshold: 3 + readinessProbe: + exec: + command: + - "/bin/sh" + - "-i" + - "-c" + - psql 127.0.0.1 -U ${POSTGRES_USER} -q -d ${POSTGRES_DB} -c 'SELECT 1' + initialDelaySeconds: 5 + timeoutSeconds: 1 + periodSeconds: 5 + failureThreshold: 3 + volumeMounts: + - name: keycloak-postgresql-data + mountPath: "/var/lib/postgresql/data" + subPath: pgdata + resources: + {{- toYaml .Values.keycloak.postgresResources | nindent 10 }} + volumes: + - name: keycloak-postgresql-data + {{- if .Values.keycloak.persistent }} + persistentVolumeClaim: + claimName: {{ .Release.Name }}-keycloak-postgresql + {{- else }} + emptyDir: + medium: '' + {{- end }} +{{- end }} \ No newline at end of file diff --git a/helm/web-app/templates/keycloak-postgres-pvc.yaml b/helm/web-app/templates/keycloak-postgres-pvc.yaml new file mode 100644 index 00000000..f9cecd82 --- /dev/null +++ b/helm/web-app/templates/keycloak-postgres-pvc.yaml @@ -0,0 +1,40 @@ +{{- /* +Copyright 2025 GoodRx, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/}} + +{{- if and .Values.keycloak.enabled .Values.keycloak.persistent }} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ .Release.Name }}-keycloak-postgresql + namespace: {{ .Release.Namespace }} + labels: + app: {{ .Release.Name }} + component: keycloak-postgresql + annotations: + {{- with .Values.keycloak.pvcAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ .Values.keycloak.volumeSize }} + {{- if hasKey .Values.keycloak "storageClassName" }} + storageClassName: {{ .Values.keycloak.storageClassName }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/helm/web-app/templates/keycloak-secret.yaml b/helm/web-app/templates/keycloak-secret.yaml new file mode 100644 index 00000000..1acd915a --- /dev/null +++ b/helm/web-app/templates/keycloak-secret.yaml @@ -0,0 +1,44 @@ +{{- /* +Copyright 2025 GoodRx, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/}} + +{{- if .Values.keycloak.enabled }} +{{- if not .Values.keycloak.secretRef }} +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Release.Name }}-keycloak-admin + namespace: {{ .Release.Namespace }} + labels: + app: {{ .Release.Name }} + component: keycloak +type: kubernetes.io/basic-auth +stringData: + username: {{ .Values.keycloak.adminUsername }} + postgresUsername: {{ .Values.keycloak.postgresUsername }} +data: + {{- if .Values.keycloak.adminPassword }} + password: {{ .Values.keycloak.adminPassword | b64enc | quote }} + {{- else }} + password: {{ randAlphaNum 40 | b64enc | quote }} + {{- end }} + {{- if .Values.keycloak.postgresPassword }} + postgresPassword: {{ .Values.keycloak.postgresPassword | b64enc | quote }} + {{- else }} + postgresPassword: {{ randAlphaNum 32 | b64enc | quote }} + {{- end }} +{{- end }} +{{- end }} \ No newline at end of file diff --git a/helm/web-app/templates/keycloak-service.yaml b/helm/web-app/templates/keycloak-service.yaml new file mode 100644 index 00000000..71b2e2eb --- /dev/null +++ b/helm/web-app/templates/keycloak-service.yaml @@ -0,0 +1,56 @@ +{{- /* +Copyright 2025 GoodRx, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/}} + +{{- if .Values.keycloak.enabled }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }}-keycloak + namespace: {{ .Release.Namespace }} + labels: + app: {{ .Release.Name }} + component: keycloak +spec: + type: {{ .Values.keycloak.serviceType | default "ClusterIP" }} + ports: + - protocol: TCP + port: 8080 + targetPort: 8080 + name: http + selector: + app: {{ .Release.Name }} + component: keycloak +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }}-keycloak-postgresql + namespace: {{ .Release.Namespace }} + labels: + app: {{ .Release.Name }} + component: keycloak-postgresql +spec: + type: ClusterIP + ports: + - name: postgresql + protocol: TCP + port: 5432 + targetPort: 5432 + selector: + app: {{ .Release.Name }} + component: keycloak-postgresql +{{- end }} \ No newline at end of file diff --git a/sysops/tilt/ngrok-keycloak.yaml b/sysops/tilt/ngrok-keycloak.yaml new file mode 100644 index 00000000..11d5dff9 --- /dev/null +++ b/sysops/tilt/ngrok-keycloak.yaml @@ -0,0 +1,61 @@ +# Copyright 2025 GoodRx, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ngrok-keycloak + namespace: lifecycle-app +spec: + replicas: 1 + selector: + matchLabels: + app: ngrok-keycloak + template: + metadata: + labels: + app: ngrok-keycloak + spec: + containers: + - name: ngrok + image: ngrok/ngrok:latest + command: ['ngrok'] + args: + - 'http' + - '--hostname=$(NGROK_KEYCLOAK_DOMAIN)' + - '--log=stdout' + - '--log-level=debug' + - 'lifecycle-keycloak:8080' # point at the Keycloak Service's name & port + + envFrom: + - secretRef: + name: ngrok-secret + # We'll expose port 4040 for the Ngrok admin UI + ports: + - containerPort: 4040 + name: ngrok-admin +--- +apiVersion: v1 +kind: Service +metadata: + name: ngrok-keycloak + namespace: lifecycle-app +spec: + type: ClusterIP + selector: + app: ngrok-keycloak + ports: + - port: 4040 + targetPort: 4040 + name: ngrok-admin