├── .dockerignore ├── .eslintrc.js ├── .gitattributes ├── .github └── workflows │ └── github-ci.yml ├── .gitignore ├── .husky └── pre-commit ├── .mocharc.js ├── .prettierrc.js ├── .vscode ├── launch.json └── settings.json ├── @types └── README ├── CONTRIBUTING.md ├── Dockerfile ├── README.md ├── bin └── kube-auth-proxy ├── config └── kube-auth-proxy.example.yaml ├── crds └── kube-auth-proxy-proxy-target-crd.yaml ├── examples ├── kube-auth-proxy-github.yaml ├── kube-auth-proxy-minikube.yaml └── proxy-target-crd.yaml ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── TargetManager.ts ├── args.ts ├── authModules │ ├── AuthModule.ts │ ├── github.ts │ └── index.ts ├── command.ts ├── config.ts ├── index.ts ├── k8sConfig │ ├── ConfigWatcher.ts │ ├── K8sWatcher.ts │ ├── annotationNames.ts │ ├── k8sUtils.ts │ └── types.ts ├── metrics.ts ├── server │ ├── authentication.ts │ ├── authorization.ts │ ├── express-types.ts │ ├── findTarget.ts │ ├── index.ts │ ├── proxy.ts │ ├── session.ts │ └── websocket.ts ├── targets │ ├── authorization.ts │ ├── index.ts │ └── validation.ts ├── types.ts ├── ui │ ├── loginScreen.ts │ └── targetList.ts └── utils │ ├── logger.ts │ ├── server.ts │ └── utils.ts ├── test ├── configTest.ts ├── fixtures │ ├── MockAuthModule.ts │ ├── makeSessionCookie.ts │ ├── mockK8sApi.ts │ ├── mockProxyTargetManager.ts │ └── testServer.ts ├── server │ ├── serverTest.ts │ └── serverWsTest.ts ├── targets │ ├── targetsTest.ts │ └── validationTest.ts └── tsconfig.json └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | .github 2 | .husky 3 | .vscode 4 | coverage 5 | dist 6 | .dockerignore 7 | .eslintrc.js 8 | .git 9 | .gitignore 10 | Dockerfile 11 | node_modules 12 | test -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: './tsconfig.json', 5 | }, 6 | extends: ['plugin:@typescript-eslint/recommended', 'prettier'], 7 | rules: { 8 | '@typescript-eslint/explicit-function-return-type': 'off', 9 | '@typescript-eslint/no-explicit-any': 'off', 10 | '@typescript-eslint/explicit-member-accessibility': 'off', 11 | '@typescript-eslint/no-use-before-define': 'off', 12 | '@typescript-eslint/no-inferrable-types': 'off', 13 | // typescript compiler has better unused variable checking. 14 | '@typescript-eslint/no-unused-vars': 'off', 15 | }, 16 | overrides: [ 17 | { 18 | files: ['test/**/*.ts', 'test/**/*.tsx'], 19 | parserOptions: { 20 | project: './test/tsconfig.json', 21 | }, 22 | rules: { 23 | '@typescript-eslint/no-non-null-assertion': 'off', 24 | '@typescript-eslint/no-object-literal-type-assertion': 'off', 25 | }, 26 | }, 27 | ], 28 | }; 29 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto -------------------------------------------------------------------------------- /.github/workflows/github-ci.yml: -------------------------------------------------------------------------------- 1 | name: GitHub CI 2 | on: 3 | push: 4 | # Publish `master` as Docker `latest` image. 5 | branches: 6 | - master 7 | # Publish `v1.2.3` tags as releases. 8 | tags: 9 | - v* 10 | # Run tests for any PRs. 11 | pull_request: 12 | 13 | env: 14 | IMAGE_NAME: jwalton/kube-auth-proxy 15 | 16 | jobs: 17 | test: 18 | name: Build and Test 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Setup Node 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: '12' 26 | - name: npm install 27 | run: npm ci 28 | - run: npm run test 29 | push: 30 | name: Push to Docker Hub 31 | needs: test 32 | runs-on: ubuntu-latest 33 | if: github.event_name == 'push' 34 | steps: 35 | - uses: actions/checkout@v2 36 | - name: Build Docker Image 37 | run: docker build --target release --tag $IMAGE_NAME . 38 | - name: Login to Docker Hub 39 | run: docker login -u jwalton -p "${{ secrets.DOCKER_HUB_TOKEN }}" 40 | - name: Push image 41 | run: | 42 | # Strip git ref prefix from version 43 | VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') 44 | # Strip "v" prefix from tag name 45 | [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//') 46 | # Use Docker `latest` tag convention 47 | [ "$VERSION" == "master" ] && VERSION=latest 48 | echo IMAGE_NAME=$IMAGE_NAME 49 | echo VERSION=$VERSION 50 | docker tag $IMAGE_NAME $IMAGE_NAME:$VERSION 51 | docker push $IMAGE_NAME:$VERSION 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /notes.md 3 | /dist 4 | /.nyc_output 5 | /coverage 6 | /npm-debug.log 7 | /yarn-error.log 8 | /config/kube-auth-proxy.yaml 9 | /examples/kube-auth-proxy-minikube-priv.yaml -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx --no-install pretty-quick --staged 2 | npx --no-install lint-staged 3 | -------------------------------------------------------------------------------- /.mocharc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extension: ['js', 'jsx', 'ts', 'tsx'], 3 | require: ['ts-node/register'], 4 | reporter: 'spec', 5 | }; 6 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'es5', 3 | printWidth: 100, 4 | tabWidth: 4, 5 | semi: true, 6 | singleQuote: true, 7 | overrides: [ 8 | { 9 | files: '*.md', 10 | options: { 11 | tabWidth: 2, 12 | }, 13 | }, 14 | { 15 | files: '*.yml', 16 | options: { 17 | tabWidth: 2, 18 | }, 19 | }, 20 | { 21 | files: '*.yaml', 22 | options: { 23 | tabWidth: 2, 24 | }, 25 | }, 26 | ], 27 | }; 28 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "skipFiles": ["/**"], 12 | "preLaunchTask": "npm: build", 13 | "program": "${workspaceFolder}/dist/command.js", 14 | "outFiles": ["${workspaceFolder}/dist/*.js"], 15 | "args": ["-v"] 16 | }, 17 | { 18 | "name": "Jest Tests (Current File)", 19 | "type": "node", 20 | "request": "launch", 21 | "runtimeArgs": [ 22 | "--inspect-brk", 23 | "${workspaceRoot}/node_modules/.bin/jest", 24 | "--runInBand", 25 | "${relativeFile}" 26 | ], 27 | "console": "integratedTerminal", 28 | "internalConsoleOptions": "neverOpen" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"] 4 | } 5 | -------------------------------------------------------------------------------- /@types/README: -------------------------------------------------------------------------------- 1 | Custom .d.ts files go here. -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This project uses [semantic-release](https://github.com/semantic-release/semantic-release) 4 | so commit messages should follow [Angular commit message conventions](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#-git-commit-guidelines). 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Dockerfile for Loop. 2 | FROM node:16-alpine as build 3 | WORKDIR /opt/app/ 4 | 5 | # Add package.json and run `npm install` first, to generate a cached layer for faster local builds. 6 | ADD package.json package-lock.json /opt/app/ 7 | RUN npm install -g npm && \ 8 | npm ci && \ 9 | rm -rf ~/.npm ~/.cache 10 | ADD . /opt/app/ 11 | RUN npm run build 12 | 13 | # `releaseIntermediate` image is an intermediate image where we do our build release. 14 | FROM build as releaseIntermediate 15 | # Delete npm devDepenedencies. 16 | RUN npm prune --production 17 | # Delete source code and tests and other stuff we don't need. 18 | RUN rm -rf src test 19 | 20 | # This is the final release image. It's created from `base` so it has none of 21 | # the dev dependencies at the OS level, and then we copy the app from `releaseIntermediate` 22 | # so we have none of the dev dependencies from NPM either. 23 | FROM node:16-alpine as release 24 | WORKDIR /opt/app/ 25 | 26 | RUN apk --no-cache add tini 27 | COPY --from=releaseIntermediate /opt/app/ /opt/app/ 28 | 29 | USER node 30 | EXPOSE 80 31 | ENTRYPOINT [ "tini", "--", "node", "./bin/kube-auth-proxy"] 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kube-auth-proxy 2 | 3 | [![NPM version](https://badge.fury.io/js/kube-auth-proxy.svg)](https://npmjs.org/package/kube-auth-proxy) 4 | ![Build Status](https://github.com/jwalton/kube-auth-proxy/workflows/GitHub%20CI/badge.svg) 5 | [![Coverage Status](https://coveralls.io/repos/jwalton/kube-auth-proxy/badge.svg)](https://coveralls.io/r/jwalton/kube-auth-proxy) 6 | 7 | Securely expose your private Kubernetes services. 8 | 9 | ## BETA 10 | 11 | This project is very beta. We're using it in production, but this is undergoing 12 | active development, and may change quite a bit without warning. 13 | 14 | ## Description 15 | 16 | kube-auth-proxy is a Kubernetes-aware authorizing reverse proxy, designed as 17 | a replacement for oauth2_proxy. 18 | 19 | You may have a number of "internal" services, such as Prometheus, Grafana, 20 | Kibana, the Kubernetes dashboard, or others, which you'd like to make available 21 | on the public internet, but which you'd like to control who can access. 22 | kube-auth-proxy makes this quite painless. 23 | 24 | The basic idea is: 25 | 26 | - Install kube-auth-proxy. Configure it with an authentication provider and 27 | some default authorization rules. 28 | - Set up an ingress controller which forwards one or more subdomains to kube-auth-proxy 29 | (e.g. "\*.internal.mydomain.com"). 30 | - For each service you want to expose, either add some annotations to that service 31 | or create a ProxyTarget custom resource for the service which desicribes what 32 | domain it should be available on (e.g. "prometheus.internal.mydomain.com"), 33 | and optionally specify some extra authorization criteria for this service. 34 | 35 | ## Motivation 36 | 37 | You can do all of this with [oauth2_proxy](https://github.com/bitly/oauth2_proxy), 38 | but the setup is quite complicated, and most of the tutorials involved assume you 39 | are using nginx for your ingress and rely on some features built into nginx to 40 | manage authentication. If you're using traefik or an AWS ALB ingress, none of 41 | these will work for you (unless you do something like set up an ALB ingress that 42 | forwards traffic to a nginx ingress). 43 | 44 | You can do all of this with [Pomerium](https://www.pomerium.io/), but unless you 45 | wrote some sort of Kubernetes Operator for Pomerium, you'd have to manage a 46 | bunch of configuration files and tell Pomerium where to find things. 47 | 48 | kube-auth-proxy was built from the ground up to specifically target Kubernetes. 49 | It's much easier to set up and use. 50 | 51 | ## Tutorial 52 | 53 | Let's suppose we have an internal service in our cluster, say prometheus, and 54 | we want to expose it at prom.internal.mydomain.com. 55 | 56 | ### Pick a Domain Name 57 | 58 | We're going to expose all your internal services under a single domain name. 59 | For example, if you pick "internal.MY-DOMAIN.COM", then when you expose the 60 | Kubernetes dashboard you might put it under "dashboard.internal.MY-DOMAIN.COM". 61 | 62 | GitHub wants a single domain name to use for OAuth callbacks (we'll use 63 | auth.internal.MY-DOMAIN.COM in this example), which means when we set a cookie, 64 | we're going to set it for some parent of that domain, which in turn means we're 65 | going to put all our other services under that same domain. 66 | 67 | ### Create a Github Oauth App 68 | 69 | Go to your GitHub organization, click on "Settings" then pick "OAuth Apps" on 70 | the left. Click the "New OAuth App" button in the upper right corner. In 71 | the "Authorization callback URL", put 72 | `http://auth.internal.MY-DOMAIN.COM/kube-auth-proxy/github/callback`. Fill in 73 | the rest of these fields however you like. When you create your app, take note 74 | of the client ID and client secret; you'll need these in the next step. 75 | 76 | ### Installation and Configuration 77 | 78 | Start with `examples/kube-auth-proxy-github.yaml`. Download this file, and update 79 | (at a minimum) `internal.MY-DOMAIN.COM`, `CLIENT-ID-HERE`, `CLIENT-SECRET-HERE`, 80 | and `MY-ORG-HERE`. Apply this with: 81 | 82 | ```sh 83 | $ kubectl apply -f `./kube-auth-proxy-github.yaml`. 84 | ``` 85 | 86 | ### Define an Ingress 87 | 88 | We need to create an ingress which forwards "\*.internal.MY-DOMAN.COM" to our 89 | new service. Unlike with oauth2-proxy or other services, kube-auth-proxy doesn't 90 | rely on features built into nginx-ingress, and should work with any ingress, 91 | include the ALB ingress or with Traefik. For example, on AWS an ALB ingress 92 | could be as simple as: 93 | 94 | ```yaml 95 | apiVersion: extensions/v1beta1 96 | kind: Ingress 97 | metadata: 98 | name: kube-auth-proxy-ingress 99 | namespace: kube-system 100 | annotations: 101 | kubernetes.io/ingress.class: alb 102 | alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]' 103 | alb.ingress.kubernetes.io/scheme: internet-facing 104 | spec: 105 | rules: 106 | - host: '*.internal.MY-DOMAIN.COM' 107 | http: 108 | paths: 109 | - path: /* 110 | backend: 111 | serviceName: kube-auth-proxy 112 | servicePort: http 113 | ``` 114 | 115 | This will create an ALB listening for https traffic on 443, and will forward all 116 | traffic to kube-auth-proxy. We need to set up DNS and certificates, but again, 117 | this is dependent on your specific setup. On AWS if you're using external-dns, 118 | it will configure your A-Records for you in Route 53. 119 | 120 | ### Annotate our Internal Service 121 | 122 | We'll add some annotations to our service so kube-auth-proxy will find it and route to it: 123 | 124 | ```yaml 125 | apiVersion: v1 126 | kind: Service 127 | metadata: 128 | name: prometheus 129 | labels: 130 | app: prometheus 131 | annotations: 132 | # Expose this as prometheus.internal.MY-DOMAIN.COM 133 | kube-auth-proxy/host: prometheus 134 | # Forward traffic to Prometheus service's "web" port. 135 | kube-auth-proxy/targetPort: web 136 | # Only allow github users in the "devOps" team in the "MY-ORG-HERE" 137 | # organization to access this service. 138 | kube-auth-proxy/githubAllowedTeams: devOps@MY-ORG-HERE 139 | spec: 140 | type: ClusterIP 141 | ports: 142 | - name: web 143 | port: 9090 144 | protocol: TCP 145 | targetPort: 9090 146 | selector: 147 | app: prometheus 148 | ``` 149 | 150 | That's all you need to do! As soon as you create/update this service, 151 | kube-auth-proxy will update it's internal configuration and start forwarding 152 | traffic to your internal service. Only Github authenticated users will 153 | be able to connect. 154 | 155 | ### See a list of services 156 | 157 | You can visit `https://auth.internal.MY-DOMAIN.COM/kube-auth-proxy/list` to see 158 | a list of services you are authorized to view. 159 | 160 | ## Service Annotations 161 | 162 | - `kube-auth-proxy/host` - The hostname to assign to the service. This can 163 | either be just the hostname (e.g. "prometheus") in which case it will combined 164 | the configured domain (e.g. "prometheus.mycompany.org"), or it can be a FQDN 165 | (e.g. "promethus.mycompany.org".) 166 | - `kube-auth-proxy/targetPort` - The port to forward traffic to. This can either 167 | be the name of a port in the service's `ports` section, or it can be a numeric 168 | port. 169 | - `kube-auth-proxy/protocol` - The protocol to use to communicate with the 170 | back end - "http" or "https". Defaults to "http". 171 | - `kube-auth-proxy/validateCertificate` - If "protocol" is https, and this is 172 | "false", then kube-auth-proxy will not validate the target service's certificate. 173 | Defaults to "true". 174 | - `kube-auth-proxy/bearerTokenSecret` - A reference to a secret, used to populate 175 | a bearer token header when requests are sent to the target service. For example: 176 | 177 | kube-auth-proxy/bearerTokenSecret: "{secretName: 'mysecret', dataName: 'token'}" 178 | 179 | would find the secret "mysecret" in the same namespace as the service, extract 180 | value "token", and inject this as a bearer token in an "Authorization" 181 | header for all requests forwarded to the service. You can also specify a 182 | `secretRegex` in place of `secretName`, in which case the first secret found 183 | which matches the regex will be used. This is handy for tokens created by a 184 | ServiceAccount. 185 | 186 | - `kube-auth-proxy/basicAuthUsername` - A username to send in basic auth 187 | credentials to the target. If `kube-auth-proxy/basicAuthPasswordSecret` or 188 | `kube-auth-proxy/basicAuthPassword` is not present, this will be ignored. 189 | - `kube-auth-proxy/basicAuthPasswordSecret` - A reference to a secret, used 190 | to send basic auth credentials to the target. For example: 191 | 192 | kube-auth-proxy/basicAuthPasswordSecret: "{secretName: 'mysecret', dataName: 'password'}" 193 | 194 | - `kube-auth-proxy/basicAuthPassword` - A password to send in basic auth 195 | credentials to the target. In general you should prefer 196 | `kube-auth-proxy/basicAuthPasswordSecret` over this. 197 | 198 | ### Conditions 199 | 200 | Note that if more than one condition is defined, they are "or"ed together. 201 | In other words, if you specify: 202 | 203 | ```yaml 204 | annotations: 205 | kube-auth-proxy/githubAllowedOrganizations: myorg 206 | kube-auth-proxy/githubAllowedUsers: jwalton 207 | ``` 208 | 209 | then the github user "jwalton" will be allowed to access your service, and anyone 210 | in "myorg" will also be able to access your service (as opposed to the more 211 | restrictive "and" case where only users with the name "jwalton" who are also 212 | members of "myorg" will be allowed to access you service). 213 | 214 | - `kube-auth-proxy/githubAllowedOrganizations` - A comma delimited list of 215 | organization names. Any user who is a member of one of these organizations 216 | will be allowed to access your service. e.g. "github,benbria". Note that 217 | this is not case sensitive. 218 | - `kube-auth-proxy/githubAllowedTeams` - A comma delimited list of github 219 | teams allowed to access this service. Team names are specified as `team@org`. 220 | For example, if your organization was named "benbria", and you had two teams 221 | called "dev" and "ops", you could grant access to both these teams with 222 | "dev@benbria,ops@benbria". Note that this is not case sensitive. 223 | - `kube-auth-proxy/githubAllowedUsers` - A comma delimited list of github 224 | users allowed to access this service. Note that this is not case sensitive. 225 | 226 | ## Configuring Services with ProxyTarget CRDs 227 | 228 | Adding annotations to services is the preferred way to configure kube-auth-proxy, 229 | but sometimes it is impractical - for example perhaps you have a service 230 | you've installed via helm, and the helm chart doesn't give you an easy way to 231 | add annotations to the service. 232 | 233 | In these cases, you can configure services using a ProxyTarget CRD. First, 234 | install the CRD: 235 | 236 | ````sh 237 | $ kubectl apply -f https://raw.githubusercontent.com/jwalton/kube-auth-proxy/master/crds/kube-auth-proxy-proxy-target-crd.yaml 238 | `` 239 | 240 | You can restrict which proxy targets will be considered in the config file using 241 | label selectors: 242 | 243 | ```yaml 244 | proxyTargetSelector: 245 | matchLabels: 246 | type: kube-auth-proxy-config 247 | ```` 248 | 249 | This make it so kube-auth-proxy will actively watch secrets and configmaps with 250 | the label "kube-auth-proxy-config". It will load all data inside any such 251 | configmap or secret found, and try to parse it as a YAML config file. Here's 252 | an example config file for the kubernetes dashboard: 253 | 254 | ```yaml 255 | apiVersion: kube-auth-proxy.thedreaming.org/v1beta1 256 | kind: ProxyTarget 257 | metadata: 258 | name: rabbit-mq 259 | labels: 260 | type: kube-auth-proxy-config 261 | target: 262 | host: dashboard 263 | to: 264 | service: kubernetes-dashboard 265 | targetPort: 443 266 | protocol: https 267 | validateCertificate: false 268 | bearerTokenSecret: 269 | secretRegex: '^kubernetes-dashboard-token.*$' 270 | dataName: 'token' 271 | conditions: 272 | githubAllowedTeams: 273 | - devOps@MY-ORG-HERE 274 | ``` 275 | 276 | Inside a `target`, you can use (almost) any annotation you could use on a service 277 | (minus the "kube-auth-proxy/" prefix). Condition annotations must be in the 278 | "conditions" section. In addition, you must specify a `to` which must 279 | either be a `{targetUrl}` or a `{service, targetPort, namespace?}` object. 280 | 281 | ## Run locally in minikube 282 | 283 | ```sh 284 | $ eval $(minikube docker-env) 285 | $ docker build --target release --tag jwalton/kube-auth-proxy . 286 | $ eval $(minikube docker-env -u) 287 | $ kubectl apply -f ./examples/kube-auth-proxy-minikube.yaml 288 | $ kubectl --namespace kube-system port-forward svc/kube-auth-proxy 5050:5050 289 | ``` 290 | 291 | And then visit [http://localhost:5050](http://localhost:5050). 292 | 293 | ## Run locally in the shell 294 | 295 | - Create a file called "./config/kube-auth-proxy.yaml". 296 | - Create a config/kube-auth-proxy.yaml file: 297 | 298 | domain: localhost:5050 299 | secureCookies: false 300 | auth: 301 | github: 302 | clientID: 'YOUR-CLIENT-ID' 303 | clientSecret: 'YOUR-CLIENT-SECRET' 304 | 305 | - Run `npm start`. 306 | 307 | Copyright 2019 Jason Walton 308 | -------------------------------------------------------------------------------- /bin/kube-auth-proxy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('../dist/command.js'); 4 | -------------------------------------------------------------------------------- /config/kube-auth-proxy.example.yaml: -------------------------------------------------------------------------------- 1 | # The top-level domain to proxy connections for. 2 | domain: localhost:5050 3 | 4 | # Port number to listen on. 5 | # port: 5050 6 | 7 | # A list of Kubernetes namespaces to watch. If omitted, will watch all namespaces. 8 | # namespaces: ['prod', 'demo', 'test'] 9 | 10 | # The secret used to encrypt session cookies. If not present, a random secret 11 | # will be generated. Need to set this if you're running more than one 12 | # kube-auth-proxy. If this is not set, sessions will be terminated if 13 | # kube-auth-proxy restarts. 14 | sessionSecret: SECRET 15 | secureCookies: true 16 | 17 | auth: 18 | github: 19 | clientID: 'YOUR-CLIENT-ID-HERE' 20 | clientSecret: 'YOUR-CLIENT-SECRET-HERE' 21 | # defaultConditions: 22 | # githubAllowedOrganizations: YOUR-ORG-HERE 23 | 24 | # You can define a static set of services to forward traffic to 25 | 26 | # defaultTargets: 27 | # - host: localhost:5050 28 | # to: 29 | # targetUrl: http://localhost:3000 30 | # conditions: 31 | # githubAllowedOrganizations: YOUR-ORG-HERE 32 | -------------------------------------------------------------------------------- /crds/kube-auth-proxy-proxy-target-crd.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1beta1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | # name must match the spec fields below, and be in the form: . 5 | name: proxytargets.kube-auth-proxy.thedreaming.org 6 | spec: 7 | # group name to use for REST API: /apis// 8 | group: kube-auth-proxy.thedreaming.org 9 | # list of versions supported by this CustomResourceDefinition 10 | versions: 11 | - name: v1beta1 12 | # Each version can be enabled/disabled by Served flag. 13 | served: true 14 | # One and only one version must be marked as the storage version. 15 | storage: true 16 | # either Namespaced or Cluster 17 | scope: Namespaced 18 | names: 19 | # plural name to be used in the URL: /apis/// 20 | plural: proxytargets 21 | # singular name to be used as an alias on the CLI and for display 22 | singular: proxytarget 23 | # kind is normally the CamelCased singular type. Your resource manifests use this. 24 | kind: ProxyTarget 25 | validation: 26 | openAPIV3Schema: 27 | type: object 28 | required: 29 | - target 30 | properties: 31 | target: 32 | type: object 33 | required: 34 | - host 35 | - to 36 | properties: 37 | host: 38 | description: | 39 | kube-auth-proxy will forward traffic to this endpoint if the 40 | "host" header in the request is `${host}.${domain}` or is 41 | this string. 42 | type: string 43 | to: 44 | oneOf: 45 | - required: ['targetUrl'] 46 | properties: 47 | targetUrl: 48 | type: string 49 | - required: ['service', 'targetPort'] 50 | properties: 51 | service: 52 | type: string 53 | targetPort: 54 | oneOf: 55 | - type: string 56 | - type: integer 57 | protocol: 58 | type: string 59 | validateCertificate: 60 | type: boolean 61 | type: object 62 | properties: 63 | targetUrl: 64 | type: string 65 | service: 66 | type: string 67 | targetPort: 68 | oneOf: 69 | - type: string 70 | - type: integer 71 | namespace: 72 | type: string 73 | protocol: 74 | type: string 75 | enum: 76 | - http 77 | - https 78 | validateCertificate: 79 | type: boolean 80 | bearerTokenSecret: 81 | type: object 82 | required: 83 | - dataName 84 | properties: 85 | secretName: 86 | type: string 87 | secretRegex: 88 | type: string 89 | dataName: 90 | type: string 91 | oneOf: 92 | - required: ['secretName'] 93 | properties: 94 | secretName: 95 | type: string 96 | - required: ['secretRegex'] 97 | properties: 98 | secretRegex: 99 | type: string 100 | basicAuthUsername: 101 | type: string 102 | basicAuthPassword: 103 | type: string 104 | basicAuthPasswordSecret: 105 | type: object 106 | required: 107 | - dataName 108 | properties: 109 | secretName: 110 | type: string 111 | secretRegex: 112 | type: string 113 | dataName: 114 | type: string 115 | oneOf: 116 | - required: ['secretName'] 117 | properties: 118 | secretName: 119 | type: string 120 | - required: ['secretRegex'] 121 | properties: 122 | secretRegex: 123 | type: string 124 | conditions: 125 | type: object 126 | properties: 127 | allowedEmails: 128 | type: array 129 | items: { type: string } 130 | emailDomains: 131 | type: array 132 | items: { type: string } 133 | githubAllowedOrganizations: 134 | type: array 135 | items: { type: string } 136 | githutAllowedUsers: 137 | type: array 138 | items: { type: string } 139 | githubAllowedTeams: 140 | type: array 141 | items: { type: string } 142 | -------------------------------------------------------------------------------- /examples/kube-auth-proxy-github.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: kube-auth-proxy 5 | namespace: kube-system 6 | --- 7 | apiVersion: rbac.authorization.k8s.io/v1beta1 8 | kind: ClusterRole 9 | metadata: 10 | name: kube-auth-proxy 11 | rules: 12 | - apiGroups: [''] 13 | resources: ['services'] 14 | verbs: ['get', 'watch', 'list'] 15 | - apiGroups: [''] 16 | resources: ['secrets'] 17 | verbs: ['get', 'watch', 'list'] 18 | - apiGroups: ['kube-auth-proxy.thedreaming.org'] 19 | resources: ['proxytargets'] 20 | verbs: ['get', 'watch', 'list'] 21 | - apiGroups: [''] 22 | resources: ['configmaps'] 23 | verbs: ['get', 'watch', 'list'] 24 | --- 25 | apiVersion: rbac.authorization.k8s.io/v1beta1 26 | kind: ClusterRoleBinding 27 | metadata: 28 | name: kube-auth-proxy-viewer 29 | roleRef: 30 | apiGroup: rbac.authorization.k8s.io 31 | kind: ClusterRole 32 | name: kube-auth-proxy 33 | subjects: 34 | - kind: ServiceAccount 35 | name: kube-auth-proxy 36 | namespace: kube-system 37 | --- 38 | apiVersion: v1 39 | kind: Secret 40 | metadata: 41 | name: kube-auth-proxy-config 42 | namespace: kube-system 43 | type: Opaque 44 | stringData: 45 | kube-auth-proxy.yaml: | 46 | # The top-level domain to proxy connections for. 47 | domain: 'internal.MY-DOMAIN.COM' 48 | 49 | # A list of Kubernetes namespaces to watch. If omitted, will watch all namespaces. 50 | 51 | # namespaces: ['prod', 'demo', 'test'] 52 | 53 | # The secret used to encrypt session cookies. If not present, a random secret 54 | # will be generated. Need to set this if you're running more than one 55 | # kube-auth-proxy. If this is not set, sessions will be terminated if 56 | # kube-auth-proxy restarts. 57 | 58 | # sessionSecret: my-secret-here 59 | 60 | auth: 61 | github: 62 | clientID: 'CLIENT-ID-HERE' 63 | clientSecret: 'CLIENT-SECRET-HERE' 64 | 65 | defaultConditions: 66 | githubAllowedOrganizations: 'MY-ORG-HERE' 67 | 68 | --- 69 | apiVersion: apps/v1 70 | kind: Deployment 71 | metadata: 72 | name: kube-auth-proxy 73 | namespace: kube-system 74 | spec: 75 | replicas: 1 76 | selector: 77 | matchLabels: 78 | app: kube-auth-proxy 79 | template: 80 | metadata: 81 | labels: 82 | app: kube-auth-proxy 83 | spec: 84 | serviceAccountName: kube-auth-proxy 85 | containers: 86 | - name: kube-auth-proxy 87 | image: jwalton/kube-auth-proxy:latest 88 | ports: 89 | - name: http 90 | containerPort: 5050 91 | - name: metrics 92 | containerPort: 5051 93 | volumeMounts: 94 | - name: config 95 | mountPath: /opt/app/config 96 | readOnly: true 97 | volumes: 98 | - name: config 99 | secret: 100 | secretName: kube-auth-proxy-config 101 | --- 102 | apiVersion: v1 103 | kind: Service 104 | metadata: 105 | name: kube-auth-proxy 106 | namespace: kube-system 107 | labels: 108 | app: kube-auth-proxy 109 | spec: 110 | type: NodePort 111 | ports: 112 | - name: http 113 | port: 5050 114 | targetPort: http 115 | - name: metrics 116 | port: 5051 117 | targetPort: metrics 118 | selector: 119 | app: kube-auth-proxy 120 | -------------------------------------------------------------------------------- /examples/kube-auth-proxy-minikube.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: kube-auth-proxy 5 | namespace: kube-system 6 | --- 7 | apiVersion: rbac.authorization.k8s.io/v1beta1 8 | kind: ClusterRole 9 | metadata: 10 | name: kube-auth-proxy 11 | rules: 12 | - apiGroups: [''] 13 | resources: ['services'] 14 | verbs: ['get', 'watch', 'list'] 15 | - apiGroups: ['kube-auth-proxy.thedreaming.org'] 16 | resources: ['proxytargets'] 17 | verbs: ['get', 'watch', 'list'] 18 | - apiGroups: [''] 19 | resources: ['secrets'] 20 | verbs: ['get', 'watch', 'list'] 21 | - apiGroups: [''] 22 | resources: ['configmaps'] 23 | verbs: ['get', 'watch', 'list'] 24 | --- 25 | apiVersion: rbac.authorization.k8s.io/v1beta1 26 | kind: ClusterRoleBinding 27 | metadata: 28 | name: kube-auth-proxy-viewer 29 | roleRef: 30 | apiGroup: rbac.authorization.k8s.io 31 | kind: ClusterRole 32 | name: kube-auth-proxy 33 | subjects: 34 | - kind: ServiceAccount 35 | name: kube-auth-proxy 36 | namespace: kube-system 37 | --- 38 | apiVersion: v1 39 | kind: Secret 40 | metadata: 41 | name: kube-auth-proxy-config 42 | namespace: kube-system 43 | type: Opaque 44 | stringData: 45 | kube-auth-proxy.yaml: | 46 | domain: localhost:5050 47 | auth: 48 | github: 49 | clientID: 'CLIENT-ID-HERE' 50 | clientSecret: 'CLIENT-SECRET-HERE' 51 | --- 52 | apiVersion: apps/v1 53 | kind: Deployment 54 | metadata: 55 | name: kube-auth-proxy 56 | namespace: kube-system 57 | spec: 58 | replicas: 1 59 | selector: 60 | matchLabels: 61 | app: kube-auth-proxy 62 | template: 63 | metadata: 64 | labels: 65 | app: kube-auth-proxy 66 | spec: 67 | serviceAccountName: kube-auth-proxy 68 | containers: 69 | - name: kube-auth-proxy 70 | image: jwalton/kube-auth-proxy:latest 71 | imagePullPolicy: IfNotPresent 72 | ports: 73 | - name: http 74 | containerPort: 5050 75 | - name: metrics 76 | containerPort: 5051 77 | volumeMounts: 78 | - name: config 79 | mountPath: /opt/app/config 80 | readOnly: true 81 | volumes: 82 | - name: config 83 | secret: 84 | secretName: kube-auth-proxy-config 85 | --- 86 | apiVersion: v1 87 | kind: Service 88 | metadata: 89 | name: kube-auth-proxy 90 | namespace: kube-system 91 | labels: 92 | app: kube-auth-proxy 93 | spec: 94 | type: NodePort 95 | ports: 96 | - name: http 97 | port: 5050 98 | targetPort: http 99 | - name: metrics 100 | port: 5051 101 | targetPort: metrics 102 | selector: 103 | app: kube-auth-proxy 104 | -------------------------------------------------------------------------------- /examples/proxy-target-crd.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kube-auth-proxy.thedreaming.org/v1beta1 2 | kind: ProxyTarget 3 | metadata: 4 | name: rabbit-mq 5 | labels: 6 | type: kube-auth-proxy-config 7 | target: 8 | host: rabbitmq 9 | to: 10 | service: rabbitmq 11 | targetPort: 15672 12 | basicAuthUsername: guest 13 | basicAuthPassword: guest 14 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | coveragePathIgnorePatterns: ['/node_modules/', '/test/'], 3 | testMatch: ['/test/**/*Test.@(ts|tsx)'], 4 | transform: { 5 | '^.+\\.(ts|tsx)$': 'ts-jest', 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kube-auth-proxy", 3 | "version": "0.2.0", 4 | "description": "Securely expose your private Kubernetes services.", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "dist/**/*" 9 | ], 10 | "bin": { 11 | "kube-auth-proxy": "./bin/kube-auth-proxy" 12 | }, 13 | "scripts": { 14 | "start": "npm run build && NODE_ENV=development node ./bin/kube-auth-proxy", 15 | "test": "npm run build && npm run lint && npm run test:unittest", 16 | "test:precommit": "npm run build && lint-staged", 17 | "build": "tsc", 18 | "build:minikube": "eval $(minikube docker-env) && docker build --target release --tag jwalton/kube-auth-proxy . && eval $(minikube docker-env -u)", 19 | "build:docker": "docker build --target release --tag jwalton/kube-auth-proxy .", 20 | "clean": "rm -rf dist types coverage", 21 | "test:unittest": "tsc -p test && jest --coverage", 22 | "lint": "npm run lint:source && npm run lint:tests", 23 | "lint:source": "eslint --ext .ts --ext .tsx src", 24 | "lint:tests": "eslint --ext .ts --ext .tsx test", 25 | "prepare": "husky install", 26 | "prepublishOnly": "npm run build && npm test" 27 | }, 28 | "lint-staged": { 29 | "src/**/*.ts": [ 30 | "eslint --ext ts --ext tsx" 31 | ], 32 | "test/**/*.ts": [ 33 | "eslint --ext ts --ext tsx" 34 | ] 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "git+https://github.com/jwalton/kube-auth-proxy.git" 39 | }, 40 | "keywords": [ 41 | "kubernetes", 42 | "k8s", 43 | "auth", 44 | "oauth", 45 | "oauth2", 46 | "proxy", 47 | "oauth2_proxy" 48 | ], 49 | "author": { 50 | "name": "Jason Walton", 51 | "email": "dev@lucid.thedreaming.org", 52 | "url": "https://thedreaming.org" 53 | }, 54 | "license": "MIT", 55 | "bugs": { 56 | "url": "https://github.com/exegesis-js/jwalton/kube-auth-proxy/issues" 57 | }, 58 | "homepage": "https://github.com/jwalton/kube-auth-proxy#readme", 59 | "devDependencies": { 60 | "@types/chai": "^4.2.5", 61 | "@types/chai-as-promised": "^7.1.2", 62 | "@types/client-sessions": "^0.8.0", 63 | "@types/express": "^4.17.13", 64 | "@types/http-proxy": "^1.17.2", 65 | "@types/jest": "^27.0.2", 66 | "@types/lodash": "^4.14.149", 67 | "@types/node": "^16.11.6", 68 | "@types/passport": "^1.0.2", 69 | "@types/passport-github2": "^1.2.4", 70 | "@types/yargs": "^17.0.4", 71 | "@typescript-eslint/eslint-plugin": "^5.2.0", 72 | "@typescript-eslint/parser": "^5.2.0", 73 | "chai": "^4.2.0", 74 | "chai-as-promised": "^7.1.1", 75 | "eslint": "^8.1.0", 76 | "eslint-config-prettier": "^8.3.0", 77 | "husky": "^7.0.4", 78 | "jest": "^27.3.1", 79 | "lint-staged": "^11.2.6", 80 | "p-event": "^4.1.0", 81 | "prettier": "^2.4.1", 82 | "pretty-quick": "^3.1.1", 83 | "supertest-fetch": "^1.4.1", 84 | "ts-jest": "^27.0.7", 85 | "ts-node": "^10.4.0", 86 | "typescript": "^4.4.4" 87 | }, 88 | "dependencies": { 89 | "@kubernetes/client-node": "^0.15.1", 90 | "@octokit/rest": "^18.12.0", 91 | "ajv": "^8.6.3", 92 | "client-sessions": "^0.8.0", 93 | "cookies": "^0.8.0", 94 | "express": "^4.17.1", 95 | "http-proxy": "^1.18.0", 96 | "js-yaml": "^4.1.0", 97 | "lodash": "^4.17.15", 98 | "passport": "^0.5.0", 99 | "passport-github2": "^0.1.11", 100 | "prom-client": "^14.0.0", 101 | "promise-tools": "^2.1.0", 102 | "winston": "^3.2.1", 103 | "winston-format-debug": "^1.0.3", 104 | "ws": "^8.2.3", 105 | "yargs": "^17.2.1" 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/TargetManager.ts: -------------------------------------------------------------------------------- 1 | import * as k8s from '@kubernetes/client-node'; 2 | import _ from 'lodash'; 3 | import prometheus from 'prom-client'; 4 | import ConfigWatcher from './k8sConfig/ConfigWatcher'; 5 | import { ProxyTargetFinder } from './server/findTarget'; 6 | import { CompiledProxyTarget, conditionToString, getFqdnForTarget } from './targets'; 7 | import { authorizeUserForTarget } from './targets/authorization'; 8 | import { Condition, KubeAuthProxyUser } from './types'; 9 | import * as log from './utils/logger'; 10 | 11 | export const servicesProxied = new prometheus.Gauge({ 12 | name: 'kube_auth_proxy_forwarded_targets', 13 | help: 'Number of services being tracked by kube-auth-proxy.', 14 | }); 15 | 16 | export const serviceConflicts = new prometheus.Gauge({ 17 | name: 'kube_auth_proxy_target_conflicts', 18 | help: 'Number of services being ignored because they conflict with other services.', 19 | }); 20 | 21 | /** 22 | * Keeps track of all configured ForwardTargets. 23 | * 24 | * Note that this class doesn't know anything about Kubernetes. We could 25 | * theoretically support other configuration backends. 26 | */ 27 | export default class TargetManager implements ProxyTargetFinder { 28 | private _configWatch?: ConfigWatcher; 29 | private _domain: string; 30 | private _targetByKey: { [key: string]: CompiledProxyTarget } = {}; 31 | // This is generated from `_configsByKey` by calling `_rebuildConfigsByHost()`. 32 | private _targetsByHost: { [host: string]: CompiledProxyTarget } = {}; 33 | 34 | constructor( 35 | defaultTargets: CompiledProxyTarget[], 36 | defaultConditions: Condition[], 37 | options: { 38 | domain: string; 39 | kubeConfig?: k8s.KubeConfig; 40 | namespaces?: string[]; 41 | proxyTargetSelector?: k8s.V1LabelSelector; 42 | } 43 | ) { 44 | this._domain = options.domain; 45 | 46 | if (options.kubeConfig) { 47 | this._configWatch = new ConfigWatcher(options.kubeConfig, defaultConditions, options); 48 | 49 | this._configWatch.on('updated', (target) => { 50 | const unchanged = 51 | this._targetByKey[target.key] && 52 | _.isEqual(this._targetByKey[target.key], target); 53 | 54 | if (!unchanged) { 55 | const verb = this._targetByKey[target.key] ? 'Updating' : 'Adding'; 56 | log.info( 57 | `${verb} target ${target.host} => ${target.targetUrl} (from ${target.source})\n` + 58 | target.conditions.map((c) => ` ${conditionToString(c)}\n`) 59 | ); 60 | this._targetByKey[target.key] = target; 61 | this._rebuildConfigsByHost(); 62 | } 63 | }); 64 | this._configWatch.on('deleted', (target) => { 65 | if (this._targetByKey[target.key]) { 66 | log.info( 67 | `Removing target ${target.host} => ${target.targetUrl} (from ${target.source})` 68 | ); 69 | delete this._targetByKey[target.key]; 70 | } 71 | this._rebuildConfigsByHost(); 72 | }); 73 | 74 | this._configWatch.on('error', (err) => { 75 | log.error(err, 'Unexpected error from configuration watcher.'); 76 | process.exit(1); 77 | }); 78 | } 79 | 80 | for (const defaultTarget of defaultTargets || []) { 81 | log.info( 82 | `Adding target from static configuration ${defaultTarget.host} => ${defaultTarget.targetUrl}` 83 | ); 84 | this._targetByKey[defaultTarget.key] = defaultTarget; 85 | } 86 | this._rebuildConfigsByHost(); 87 | } 88 | 89 | /** 90 | * This regenerates this._configsByHost(). 91 | * 92 | * Keys are unique, so when a ForwardTarget is updated or deleted. it's 93 | * easy to add/update/remove it from `this._configsByKey`. The host 94 | * associated with a ForwardConfig can change, however, or it's even 95 | * possible for two different ForwardTargets to have the same conflicting 96 | * `host`. So rather than try to maintain a `_configsByHost` (which 97 | * we want to have for fast lookups when requests come in) we regenerate 98 | * this from `_configsByKey` every time there's a change. The theory 99 | * is that config changes are infrequent, so this shouldn't happen 100 | * too often, so even if it's a bit slow it's not a problem. We'll see 101 | * if that proves true in practice. :) 102 | */ 103 | private _rebuildConfigsByHost() { 104 | this._targetsByHost = {}; 105 | 106 | let count = 0; 107 | let conflicts = 0; 108 | 109 | for (const key of Object.keys(this._targetByKey)) { 110 | const target = this._targetByKey[key]; 111 | const host = getFqdnForTarget(this._domain, target); 112 | 113 | if (this._targetsByHost[host]) { 114 | conflicts++; 115 | log.warn( 116 | `Configuration from ${this._targetsByHost[host].key} conflicts with ${target.key}` 117 | ); 118 | } else { 119 | count++; 120 | this._targetsByHost[host] = target; 121 | } 122 | } 123 | 124 | servicesProxied.set(count); 125 | serviceConflicts.set(conflicts); 126 | } 127 | 128 | /** 129 | * Given the `host` header from an incoming request, find a ForwardTarget 130 | * to forward the request to. 131 | */ 132 | findTarget(host: string) { 133 | return this._targetsByHost[host]; 134 | } 135 | 136 | /** 137 | * Returns a list of all targets the user is authorized to access. 138 | */ 139 | findTargetsForUser(user: KubeAuthProxyUser) { 140 | const answer: CompiledProxyTarget[] = []; 141 | 142 | for (const host of Object.keys(this._targetsByHost)) { 143 | const target = this._targetsByHost[host]; 144 | if (authorizeUserForTarget(user, target)) { 145 | answer.push(target); 146 | } 147 | } 148 | 149 | return answer; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/args.ts: -------------------------------------------------------------------------------- 1 | import yargs from 'yargs'; 2 | import { RawKubeAuthProxyConfig } from './types'; 3 | 4 | export function parseCommandLineArgs(): Partial & { 5 | config?: string; 6 | noK8s?: boolean; 7 | } { 8 | const options = yargs 9 | .strict() 10 | .usage( 11 | 'Start the kube-auth-proxy server.\n' + 12 | 'Usage: $0 [--audit] [-a accountName] [handler...]\n' 13 | ) 14 | .options('v', { alias: 'verbose', boolean: true, describe: 'Print verbose details' }) 15 | .options('cookie-secure', { 16 | type: 'boolean', 17 | describe: 18 | 'Set secure (HTTPS) cookie flag. Defaults to true. ' + 19 | 'Use `--no-cookie-secure` to set this false.', 20 | }) 21 | .options('config', { 22 | type: 'string', 23 | describe: 'Location of the config file.', 24 | }) 25 | .options('no-k8s', { 26 | type: 'boolean', 27 | default: false, 28 | describe: 29 | 'If set, do not connect to Kubernetes to get configuration.' + 30 | '(This is mainly for development.)', 31 | }) 32 | .help('h') 33 | .alias('h', 'help') 34 | .parseSync(process.argv.slice(2)); 35 | 36 | return { 37 | config: options.config, 38 | noK8s: options['no-k8s'], 39 | secureCookies: options['cookie-secure'], 40 | logLevel: options.v ? 'debug' : undefined, 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/authModules/AuthModule.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import passport from 'passport'; 3 | import { SanitizedKubeAuthProxyConfig } from '../types'; 4 | 5 | export interface AuthModule { 6 | /** 7 | * The name of this module. 8 | */ 9 | name: string; 10 | 11 | /** 12 | * Returns false if this modules is not configured and should be disabled. 13 | */ 14 | isEnabled(config: SanitizedKubeAuthProxyConfig): boolean; 15 | 16 | /** 17 | * Returns HTML for a button the user can click on to login using this service. 18 | * 19 | * @param redirectUrl - The URL to redirect to after authentication is 20 | * successful. 21 | */ 22 | getLoginButton(redirectUrl: string): string; 23 | 24 | /** 25 | * Returns a middleware which can authenticate users. This middleware may 26 | * define routes under "/kube-auth-proxy", but will be called for all routes. 27 | * 28 | * This middleware can use the passed in `passport` instance to authenticate 29 | * the user and log them in, or can call `req.login(user, cb)` to login 30 | * a user. 31 | * 32 | * User should have, at a minimum, a `type` equal to the name of the 33 | * AuthModule, and conform to the `KubeAuthProxyUser` interface. 34 | */ 35 | authenticationMiddleware( 36 | config: SanitizedKubeAuthProxyConfig, 37 | passport: passport.Authenticator 38 | ): express.RequestHandler; 39 | } 40 | -------------------------------------------------------------------------------- /src/authModules/github.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from '@octokit/rest'; 2 | import express from 'express'; 3 | import * as passportLib from 'passport'; 4 | import { Strategy as GitHubStrategy } from 'passport-github2'; 5 | import '../server/express-types'; 6 | import { KubeAuthProxyUser, SanitizedKubeAuthProxyConfig } from '../types'; 7 | import * as log from '../utils/logger'; 8 | 9 | // Refresh the user's teams and orgs every 5 minutes. 10 | const USER_REFRESH_INTERVAL = 1000 * 60 * 5; 11 | 12 | export interface GithubUser extends KubeAuthProxyUser { 13 | type: 'github'; 14 | accessToken: string; 15 | id: string; 16 | username: string; 17 | orgs: string[]; 18 | teams: string[]; 19 | emails: string[]; 20 | timestamp: number; 21 | } 22 | 23 | export const name = 'github'; 24 | 25 | export function isEnabled(config: SanitizedKubeAuthProxyConfig) { 26 | return config.auth?.github != null; 27 | } 28 | 29 | export function getLoginButton(targetUrl: string): string { 30 | return `Login with Github`; 31 | } 32 | 33 | /** 34 | * Returns a middleware which will authenticate GitHub users. 35 | */ 36 | export function authenticationMiddleware( 37 | config: SanitizedKubeAuthProxyConfig, 38 | passport: passportLib.Authenticator 39 | ) { 40 | if (!config.auth?.github) { 41 | throw new Error('Missing github config.'); 42 | } 43 | 44 | passport.use( 45 | new GitHubStrategy( 46 | { 47 | clientID: config.auth.github.clientID, 48 | clientSecret: config.auth.github.clientSecret, 49 | callbackURL: `http://${config.authDomain}/kube-auth-proxy/github/callback`, 50 | scope: ['user:email', 'read:org'], 51 | passReqToCallback: true, 52 | }, 53 | ( 54 | _req: express.Request, 55 | accessToken: string, 56 | _refreshToken: string, 57 | profile: any, 58 | cb: (err?: Error | null, user?: object, info?: object) => void 59 | ) => { 60 | getOrgsAndTeamsForUser(accessToken) 61 | .then(({ orgs, teams }) => { 62 | const emails = profile.emails 63 | ? (profile.emails 64 | .filter((email: any) => (email as any).verified) 65 | .map((email: any) => email.value) as string[]) 66 | : []; 67 | 68 | const user: GithubUser = { 69 | type: 'github', 70 | accessToken: accessToken, 71 | id: profile.id, 72 | username: profile.username || profile.id, 73 | emails, 74 | orgs: orgs, 75 | teams: teams, 76 | timestamp: Date.now(), 77 | }; 78 | 79 | log.info(`Authenticated github user ${user.username}`); 80 | 81 | cb(null, user); 82 | }) 83 | .catch((err) => { 84 | log.error(err, 'Error fetching orgs and teams'); 85 | cb(err); 86 | }); 87 | } 88 | ) 89 | ); 90 | 91 | const router = express.Router(); 92 | 93 | router.use((req, _res, next) => { 94 | if (req.user && req.user.type === 'github') { 95 | const user = req.user as GithubUser; 96 | if (Date.now() - (user.timestamp ?? 0) > USER_REFRESH_INTERVAL) { 97 | log.debug(`Refreshing github orgs and teams for user ${user.username}`); 98 | getOrgsAndTeamsForUser(user.accessToken).then(({ orgs, teams }) => { 99 | const updatedUser: GithubUser = { 100 | ...user, 101 | orgs, 102 | teams, 103 | timestamp: Date.now(), 104 | }; 105 | req.login(updatedUser, next); 106 | }); 107 | } else { 108 | next(); 109 | } 110 | } else { 111 | next(); 112 | } 113 | }); 114 | 115 | router.get('/kube-auth-proxy/github', (req, res, next) => { 116 | const redirectTo = req.query.redirect; 117 | 118 | passport.authenticate('github', { state: `rd=${redirectTo}` })(req, res, next); 119 | }); 120 | 121 | router.get( 122 | '/kube-auth-proxy/github/callback', 123 | passport.authenticate('github', { failureRedirect: '/' }), 124 | (req, res) => { 125 | if (typeof req.query.state != 'string') { 126 | res.send('Missing state'); 127 | return; 128 | } 129 | 130 | const state = new URLSearchParams(req.query.state); 131 | const redirectTarget = state.get('rd'); 132 | 133 | // User is now authenticated. Redirect them to wherever they were going in the first place. 134 | if (redirectTarget) { 135 | res.redirect(redirectTarget); 136 | } else { 137 | res.send('Logged in - please try your request again.'); 138 | } 139 | } 140 | ); 141 | 142 | return router; 143 | } 144 | 145 | /** 146 | * Returns a list of organizations and teams the user belongs to. 147 | */ 148 | async function getOrgsAndTeamsForUser(accessToken: string) { 149 | const octokit = new Octokit({ auth: accessToken }); 150 | const orgs = (await octokit.orgs.listForAuthenticatedUser()).data.map((org) => 151 | org.login.toLowerCase() 152 | ); 153 | const teams = (await octokit.teams.listForAuthenticatedUser()).data.map((team) => 154 | `${team.name}@${team.organization.login}`.toLowerCase() 155 | ); 156 | return { orgs, teams }; 157 | } 158 | -------------------------------------------------------------------------------- /src/authModules/index.ts: -------------------------------------------------------------------------------- 1 | import * as github from './github'; 2 | import { AuthModule } from './AuthModule'; 3 | 4 | const modules: AuthModule[] = [github]; 5 | export default modules; 6 | -------------------------------------------------------------------------------- /src/command.ts: -------------------------------------------------------------------------------- 1 | import * as k8s from '@kubernetes/client-node'; 2 | import _ from 'lodash'; 3 | import { parseCommandLineArgs } from './args'; 4 | import authModules from './authModules'; 5 | import { DEFAULT_METRICS_PORT, readConfig, validateConfig } from './config'; 6 | import { startMetricsServer } from './metrics'; 7 | import { startServer as startProxyServer } from './server/index'; 8 | import TargetManager from './TargetManager'; 9 | import { CompiledProxyTarget, compileProxyTarget, parseTargetsFromFile } from './targets'; 10 | import * as log from './utils/logger'; 11 | 12 | async function start() { 13 | const cliOptions = await parseCommandLineArgs(); 14 | // If there was a logLevel specified in the CLI, use it right away. 15 | if (cliOptions.logLevel) { 16 | log.setLevel(cliOptions.logLevel); 17 | } 18 | 19 | const fileConfig = await readConfig(cliOptions.config); 20 | 21 | const rawConfig = _.merge(fileConfig, cliOptions); 22 | const config = validateConfig(rawConfig); 23 | if (config.logLevel) { 24 | log.setLevel(config.logLevel); 25 | } 26 | 27 | const enabledAuthModles = authModules.filter((module) => module.isEnabled(config)); 28 | log.info(`Enabled authentication modules: ${enabledAuthModles.map((m) => m.name).join(', ')}`); 29 | 30 | let kubeConfig: k8s.KubeConfig | undefined; 31 | if (!cliOptions.noK8s) { 32 | log.info('Loding Kubernetes configuration.'); 33 | kubeConfig = new k8s.KubeConfig(); 34 | kubeConfig.loadFromDefault(); 35 | } 36 | 37 | const k8sApi = kubeConfig ? kubeConfig.makeApiClient(k8s.CoreV1Api) : undefined; 38 | const rawDefaultTargets = parseTargetsFromFile( 39 | undefined, 40 | 'static-config', 41 | 'static-config', 42 | config.defaultTargets 43 | ); 44 | const defaultTargets: CompiledProxyTarget[] = []; 45 | for (const defaultTarget of rawDefaultTargets) { 46 | defaultTargets.push( 47 | await compileProxyTarget(k8sApi, defaultTarget, config.defaultConditions) 48 | ); 49 | } 50 | 51 | // Watch Kubernetes for services to proxy to. 52 | const proxyTargets = new TargetManager(defaultTargets, config.defaultConditions, { 53 | kubeConfig, 54 | domain: config.domain, 55 | namespaces: config.namespaces, 56 | proxyTargetSelector: config.proxyTargetSelector, 57 | }); 58 | 59 | startProxyServer(config, proxyTargets, authModules); 60 | startMetricsServer(config.metricsPort || DEFAULT_METRICS_PORT); 61 | } 62 | 63 | start().catch((err) => { 64 | log.error(err); 65 | }); 66 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import { promises as fs } from 'fs'; 3 | import jsYaml from 'js-yaml'; 4 | import { getConditions } from './targets'; 5 | import { validateProxyTarget } from './targets/validation'; 6 | import { RawKubeAuthProxyConfig, SanitizedKubeAuthProxyConfig } from './types'; 7 | 8 | export const DEFAULT_PORT = 5050; 9 | export const DEFAULT_METRICS_PORT = 5051; 10 | export const DEFAULT_COOKIE_NAME = 'kube-auth-proxy'; 11 | 12 | /** 13 | * Read in and validate the kube-auth-proxy config file. 14 | */ 15 | export async function readConfig( 16 | configFile: string = './config/kube-auth-proxy.yaml' 17 | ): Promise { 18 | const configContents = (await fs.readFile(configFile, { encoding: 'utf-8' })) as string; 19 | const config = jsYaml.load(configContents) as RawKubeAuthProxyConfig; 20 | return config; 21 | } 22 | 23 | function checkType(name: string, value: any, expectedType: string) { 24 | if (typeof value !== expectedType) { 25 | throw new Error(`${name} must be of type ${expectedType}`); 26 | } 27 | } 28 | 29 | function parseInteger(name: string, value: any, def: number) { 30 | if (value == null) { 31 | return def; 32 | } 33 | 34 | const asNumber = typeof value === 'number' ? value : parseInt(value, 10); 35 | if (isNaN(asNumber)) { 36 | throw new Error(`${name} must be a number`); 37 | } 38 | return asNumber; 39 | } 40 | 41 | /** 42 | * Validate the configuration. 43 | */ 44 | export function validateConfig(config: RawKubeAuthProxyConfig): SanitizedKubeAuthProxyConfig { 45 | if (!config || !config.domain) { 46 | throw new Error(`domain required in configuration.`); 47 | } 48 | 49 | if (!config.authDomain) { 50 | if (config.domain === 'localhost' || config.domain.startsWith('localhost:')) { 51 | config.authDomain = config.domain; 52 | } else { 53 | config.authDomain = `auth.${config.domain}`; 54 | } 55 | } 56 | 57 | if (!config.sessionCookieName) { 58 | config.sessionCookieName = DEFAULT_COOKIE_NAME; 59 | } 60 | checkType('sessionCookieName', config.sessionCookieName, 'string'); 61 | 62 | config.sessionSecret = config.sessionSecret ?? crypto.randomBytes(32).toString('hex'); 63 | 64 | config.secureCookies = config.secureCookies ?? true; 65 | checkType('secureCookies', config.secureCookies, 'boolean'); 66 | 67 | if ( 68 | config.namespaces && 69 | (!Array.isArray(config.namespaces) || 70 | !config.namespaces.every((namespace) => typeof namespace === 'string')) 71 | ) { 72 | throw new Error(`namespaces must be an array of strings`); 73 | } 74 | 75 | config.port = parseInteger('port', config.port, DEFAULT_PORT); 76 | config.metricsPort = parseInteger('metricsPort', config.metricsPort, DEFAULT_METRICS_PORT); 77 | 78 | if (!config.auth?.github?.clientID || !config.auth?.github?.clientSecret) { 79 | throw new Error('Missing github credentials in config file.'); 80 | } 81 | 82 | // FIXME: Better validation for these - if someone misspells a key, we don't 83 | // want to allow users we shouldn't. We should pass these to the AuthMod 84 | // to validate/clean up. We should also disallow conditions with no "type". 85 | (config as SanitizedKubeAuthProxyConfig).defaultConditions = getConditions( 86 | config.defaultConditions || {}, 87 | [] 88 | ); 89 | 90 | config.defaultTargets = (config.defaultTargets || []).map((proxyTarget, index) => ({ 91 | ...proxyTarget, 92 | source: 'static-config', 93 | key: `config-${index}`, 94 | })); 95 | (config.defaultTargets || []).forEach((target, index) => { 96 | try { 97 | validateProxyTarget(target); 98 | } catch (err) { 99 | throw new Error( 100 | `Error validating static config defaultTargets[${index}]: ${ 101 | (err as Error).message 102 | }.` 103 | ); 104 | } 105 | }); 106 | 107 | return config as SanitizedKubeAuthProxyConfig; 108 | } 109 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // TODO: Export some things here! 2 | -------------------------------------------------------------------------------- /src/k8sConfig/ConfigWatcher.ts: -------------------------------------------------------------------------------- 1 | import * as k8s from '@kubernetes/client-node'; 2 | import { EventEmitter } from 'events'; 3 | import _ from 'lodash'; 4 | import prometheus from 'prom-client'; 5 | import { 6 | CompiledProxyTarget, 7 | compileProxyTarget, 8 | isServiceNameTargetSpecifier, 9 | RawProxyTarget, 10 | } from '../targets'; 11 | import { Condition, RawCondition } from '../types'; 12 | import * as log from '../utils/logger'; 13 | import { parseCommaDelimitedList } from '../utils/utils'; 14 | import * as annotationNames from './annotationNames'; 15 | import { labelSelectorToQueryParam, parseSecretSpecifier } from './k8sUtils'; 16 | import K8sWatcher from './K8sWatcher'; 17 | import { ProxyTargetCrd } from './types'; 18 | 19 | export const updateSeen = new prometheus.Counter({ 20 | name: 'kube_auth_proxy_k8s_update_seen', 21 | help: 'Number of times Kubernetes told us a service/configmap/secret updated.', 22 | }); 23 | 24 | export const serviceUpdates = new prometheus.Counter({ 25 | name: 'kube_auth_proxy_k8s_service_config_updated', 26 | help: 'Number of times a service was updated or deleted, from Kubernetes.', 27 | }); 28 | 29 | export const staleUpdates = new prometheus.Counter({ 30 | name: 'kube_auth_proxy_k8s_service_stale_updates', 31 | help: 'Number of times a service/configmap/secret was ignored because it was stale.', 32 | }); 33 | 34 | export const serviceUpdateErrors = new prometheus.Counter({ 35 | name: 'kube_auth_proxy_k8s_service_update_errors', 36 | help: 'Number of times a service or services could not be updated because of an error.', 37 | }); 38 | 39 | declare interface ConfigWatcher { 40 | emit(event: 'updated', data: CompiledProxyTarget): boolean; 41 | emit(event: 'deleted', data: CompiledProxyTarget): boolean; 42 | emit(event: 'error', err: Error): boolean; 43 | on(event: 'updated', listener: (data: CompiledProxyTarget) => void): this; 44 | on(event: 'deleted', listener: (data: CompiledProxyTarget) => void): this; 45 | on(event: 'error', listener: (err: Error) => void): this; 46 | } 47 | 48 | /** 49 | * Watches for configuration changes across all namespaces. 50 | */ 51 | class ConfigWatcher extends EventEmitter { 52 | // TODO: Look into replacing this with an "informer"? 53 | private _serviceWatcher: K8sWatcher; 54 | private _proxyTargetWatcher: K8sWatcher | undefined; 55 | 56 | private _configsBySource: { [source: string]: CompiledProxyTarget[] } = {}; 57 | private _namespaces: string[] | undefined; 58 | 59 | // Used to keep track of how often each object has been updated, so 60 | // if we get two updates back to back, we don't accidentally take the 61 | // result of the first update and discard the second due to async 62 | // ordering issues. 63 | private _objectRevision: { [source: string]: number } = {}; 64 | 65 | constructor( 66 | kubeConfig: k8s.KubeConfig, 67 | defaultConditions: Condition[], 68 | options: { 69 | namespaces?: string[]; 70 | proxyTargetSelector?: k8s.V1LabelSelector; 71 | } = {} 72 | ) { 73 | super(); 74 | 75 | this._namespaces = options.namespaces; 76 | 77 | const k8sApi = kubeConfig.makeApiClient(k8s.CoreV1Api); 78 | this._serviceWatcher = this._watchObjects({ 79 | kubeConfig, 80 | k8sApi, 81 | defaultConditions, 82 | type: 'service', 83 | resourceUrl: '/api/v1/services', 84 | getRawTargets: serviceToTargets, 85 | }); 86 | 87 | this._proxyTargetWatcher = this._watchObjects({ 88 | kubeConfig, 89 | k8sApi, 90 | defaultConditions, 91 | type: 'proxyTarget', 92 | resourceUrl: '/apis/kube-auth-proxy.thedreaming.org/v1beta1/proxytargets', 93 | labelSelector: options.proxyTargetSelector, 94 | getRawTargets(proxyTarget, source) { 95 | if (isServiceNameTargetSpecifier(proxyTarget.target.to)) { 96 | proxyTarget.target.to.namespace = 97 | proxyTarget.target.to.namespace || proxyTarget.metadata?.namespace; 98 | } 99 | proxyTarget.target.source = source; 100 | proxyTarget.target.key = source; 101 | return [proxyTarget.target]; 102 | }, 103 | onErr(err: Error) { 104 | if (err.message === 'Not Found') { 105 | log.warn('ProxyTarget CRD not installed.'); 106 | } else { 107 | throw err; 108 | } 109 | }, 110 | }); 111 | } 112 | 113 | /** 114 | * Stop listening to services. 115 | */ 116 | close() { 117 | this.removeAllListeners(); 118 | 119 | this._serviceWatcher.close(); 120 | this._proxyTargetWatcher?.close(); 121 | 122 | for (const source of Object.keys(this._configsBySource)) { 123 | this._deleteSource(source); 124 | } 125 | } 126 | 127 | private _watchObjects(params: { 128 | kubeConfig: k8s.KubeConfig; 129 | k8sApi: k8s.CoreV1Api; 130 | defaultConditions: Condition[]; 131 | type: string; 132 | resourceUrl: string; 133 | getRawTargets: (obj: T, source: string) => RawProxyTarget[]; 134 | labelSelector?: k8s.V1LabelSelector; 135 | onErr?: (err: Error) => void; 136 | }) { 137 | const { 138 | kubeConfig, 139 | k8sApi, 140 | defaultConditions, 141 | type, 142 | resourceUrl, 143 | labelSelector, 144 | getRawTargets, 145 | onErr, 146 | } = params; 147 | 148 | const watchUrl = `${resourceUrl}${labelSelectorToQueryParam(labelSelector)}`; 149 | const watcher: K8sWatcher = new K8sWatcher(kubeConfig, watchUrl); 150 | 151 | log.info(`Watching ${type}s for updates (${watchUrl})`); 152 | 153 | watcher.on('updated', (obj) => { 154 | if (obj.metadata?.name) { 155 | const namespace = obj.metadata.namespace || 'default'; 156 | const name = obj.metadata.name; 157 | 158 | if (this._namespaces && !this._namespaces.includes(namespace)) { 159 | log.debug( 160 | `Ignoring secret ${namespace}/${name} because it's not in a watched namespace` 161 | ); 162 | return; 163 | } 164 | 165 | updateSeen.inc(); 166 | 167 | const source = toSource(type, namespace, name); 168 | const rawTargets = getRawTargets(obj, source); 169 | this._updateSource(k8sApi, namespace, rawTargets, source, defaultConditions); 170 | } 171 | }); 172 | 173 | watcher.on('deleted', (secret) => { 174 | if (secret.metadata?.name) { 175 | const namespace = secret.metadata.namespace || 'default'; 176 | const source = toSource(type, namespace, secret.metadata.name); 177 | this._deleteSource(source); 178 | } 179 | }); 180 | 181 | watcher.on('error', (err) => { 182 | if (onErr) { 183 | try { 184 | onErr(err); 185 | } catch (err) { 186 | this.emit('error', err as Error); 187 | } 188 | } else { 189 | this.emit('error', err); 190 | } 191 | }); 192 | 193 | return watcher; 194 | } 195 | 196 | /** 197 | * Called when a source (e.g. a service or configmap) is removed from the system, 198 | * or has no targets defined. 199 | */ 200 | private _deleteSource(source: string) { 201 | if (this._configsBySource[source]) { 202 | log.debug(`${source} deleted`); 203 | 204 | for (const target of this._configsBySource[source]) { 205 | this.emit('deleted', target); 206 | serviceUpdates.inc(); 207 | } 208 | delete this._configsBySource[source]; 209 | } 210 | } 211 | 212 | /** 213 | * Called when a source (e.g. a service or configmap) is updated. 214 | */ 215 | private async _updateSource( 216 | k8sApi: k8s.CoreV1Api, 217 | namespace: string, 218 | rawTargets: RawProxyTarget[], 219 | source: string, 220 | defaultConditions: Condition[] 221 | ) { 222 | this._objectRevision[source] = (this._objectRevision[source] || 0) + 1; 223 | const revision = this._objectRevision[source]; 224 | 225 | if (rawTargets.length === 0) { 226 | log.debug(`${source} deconfigured`); 227 | this._deleteSource(source); 228 | } else { 229 | Promise.all( 230 | rawTargets.map((target) => 231 | compileProxyTarget(k8sApi, target, defaultConditions, { 232 | defaultNamespace: namespace, 233 | }) 234 | ) 235 | ) 236 | .then((compiledTargets) => { 237 | if (this._objectRevision[source] !== revision) { 238 | log.debug(`Ignoring stale update for ${source}`); 239 | staleUpdates.inc(); 240 | // If `compileProxyTarget()` has to do async operations, 241 | // those operations could resolve in a different order. 242 | // e.g. if a service has a bearer token configured, 243 | // `compileProxyTarget()` would have to load that from K8s. 244 | // If that service is subsequently deleted, then 245 | // `compileProxyTarget()` would just return `undefined`. 246 | // We want to make sure if those two things happen 247 | // back-to-back, and the second promise resolves first, 248 | // that we end in a state where the service is deleted. 249 | } else { 250 | log.debug(`Updated ${source}`); 251 | 252 | const exisitng: CompiledProxyTarget[] = this._configsBySource[source] || []; 253 | 254 | // If there are any services which used to be in this 255 | // config which are now missing, delete the services. 256 | const deleted = _.differenceBy( 257 | exisitng, 258 | compiledTargets, 259 | (target) => target.key 260 | ); 261 | for (const target of deleted) { 262 | this.emit('deleted', target); 263 | serviceUpdates.inc(); 264 | } 265 | 266 | this._configsBySource[source] = compiledTargets; 267 | for (const target of compiledTargets) { 268 | this.emit('updated', target); 269 | serviceUpdates.inc(); 270 | } 271 | } 272 | }) 273 | .catch((err) => { 274 | log.error(err); 275 | serviceUpdateErrors.inc(); 276 | }); 277 | } 278 | } 279 | } 280 | 281 | function toSource(type: string, namespace: string, name: string) { 282 | return `${type}/${namespace}/${name}`; 283 | } 284 | 285 | /** 286 | * Extract configuration for a service from the service's annotations. 287 | */ 288 | function serviceToTargets(service: k8s.V1Service, source: string): RawProxyTarget[] { 289 | const answer: RawProxyTarget[] = []; 290 | const namespace = service.metadata?.namespace || 'default'; 291 | const annotations = service.metadata?.annotations ?? {}; 292 | 293 | if (annotations[annotationNames.HOST] && service.metadata?.name && service.spec?.ports) { 294 | let conditions: RawCondition | undefined; 295 | 296 | const allowedEmails = annotations[annotationNames.ALLOWED_EMAILS]; 297 | if (allowedEmails) { 298 | conditions = conditions || {}; 299 | conditions.allowedEmails = parseCommaDelimitedList(allowedEmails); 300 | } 301 | 302 | const emailDomains = annotations[annotationNames.EMAIL_DOMAINS]; 303 | if (emailDomains) { 304 | conditions = conditions || {}; 305 | conditions.emailDomains = parseCommaDelimitedList(emailDomains); 306 | } 307 | 308 | const githubAllowedOrgs = annotations[annotationNames.GITHUB_ALLOWED_ORGS]; 309 | if (githubAllowedOrgs) { 310 | conditions = conditions || {}; 311 | conditions.githubAllowedOrganizations = parseCommaDelimitedList(githubAllowedOrgs).map( 312 | (str) => str.toLowerCase() 313 | ); 314 | } 315 | 316 | const githubAllowedTeams = annotations[annotationNames.GITHUB_ALLOWED_TEAMS]; 317 | if (githubAllowedTeams) { 318 | conditions = conditions || {}; 319 | conditions.githubAllowedTeams = parseCommaDelimitedList(githubAllowedTeams).map((str) => 320 | str.toLowerCase() 321 | ); 322 | } 323 | 324 | const githubAllowedUsers = annotations[annotationNames.GITHUB_ALLOWED_USERS]; 325 | if (githubAllowedUsers) { 326 | conditions = conditions || {}; 327 | conditions.githubAllowedUsers = parseCommaDelimitedList(githubAllowedUsers).map((str) => 328 | str.toLowerCase() 329 | ); 330 | } 331 | 332 | const bearerTokenSecret = annotations[annotationNames.BEARER_TOKEN_SECRET]; 333 | const basicAuthPasswordSecret = annotations[annotationNames.BASIC_AUTH_PASSWORD_SECRET]; 334 | const protocol = annotations[annotationNames.PROTOCOL] === 'https' ? 'https' : 'http'; 335 | const validateCertificate = annotations[annotationNames.VALIDATE_CERTIFICATE]; 336 | 337 | answer.push({ 338 | key: source, 339 | source: source, 340 | host: annotations[annotationNames.HOST], 341 | to: { 342 | service, 343 | targetPort: annotations[annotationNames.TARGET_PORT], 344 | protocol, 345 | validateCertificate: 346 | validateCertificate === 'false' || (validateCertificate as any) === false 347 | ? false 348 | : true, 349 | }, 350 | bearerTokenSecret: bearerTokenSecret 351 | ? parseSecretSpecifier( 352 | namespace, 353 | bearerTokenSecret, 354 | `service ${namespace}/${service.metadata.name}/annotations/${annotationNames.BEARER_TOKEN_SECRET}` 355 | ) 356 | : undefined, 357 | basicAuthUsername: annotations[annotationNames.BASIC_AUTH_USERNAME], 358 | basicAuthPassword: annotations[annotationNames.BASIC_AUTH_PASSWORD], 359 | basicAuthPasswordSecret: basicAuthPasswordSecret 360 | ? parseSecretSpecifier( 361 | namespace, 362 | basicAuthPasswordSecret, 363 | `service ${namespace}/${service.metadata.name}/annotations/${annotationNames.BASIC_AUTH_PASSWORD_SECRET}` 364 | ) 365 | : undefined, 366 | conditions, 367 | }); 368 | } else { 369 | const serviceName = service.metadata?.name || 'unknown'; 370 | log.debug( 371 | `Ignoring service ${namespace}/${serviceName} because it is missing host annotation.` 372 | ); 373 | } 374 | 375 | return answer; 376 | } 377 | 378 | export default ConfigWatcher; 379 | -------------------------------------------------------------------------------- /src/k8sConfig/K8sWatcher.ts: -------------------------------------------------------------------------------- 1 | import * as k8s from '@kubernetes/client-node'; 2 | import { EventEmitter } from 'events'; 3 | import prometheus from 'prom-client'; 4 | 5 | const watcherHangup = new prometheus.Counter({ 6 | name: 'kube_auth_proxy_watcher_hangup', 7 | help: 'A watcher hung up on us.', 8 | }); 9 | 10 | declare interface K8sWatcher { 11 | emit(event: 'updated', obj: T): boolean; 12 | emit(event: 'deleted', obj: T): boolean; 13 | emit(event: 'error', err: Error): boolean; 14 | /** Emitted when a resources is created or updated. */ 15 | on(event: 'updated', listener: (obj: T) => void): this; 16 | /** Emitted when a resource is deleted. */ 17 | on(event: 'deleted', listener: (obj: T) => void): this; 18 | on(event: 'error', listener: (err: Error) => void): this; 19 | } 20 | 21 | /** 22 | * Watch a Kubernetes resource, and emit `updated` and `deleted` events for it. 23 | */ 24 | class K8sWatcher extends EventEmitter { 25 | private _kubeConfig: k8s.KubeConfig; 26 | private _watch: any; 27 | 28 | /** 29 | * Create a new K8sWatcher. 30 | * 31 | * @param kubeConfig - Kubernetes cluster config. 32 | * @param endpoint - The endpoint to watch (e.g. '/api/v1/namespaces'). 33 | */ 34 | constructor(kubeConfig: k8s.KubeConfig, endpoint: string) { 35 | super(); 36 | 37 | this._kubeConfig = kubeConfig; 38 | this._watch = this._makeWatch(endpoint); 39 | } 40 | 41 | private _makeWatch(endpoint: string) { 42 | const watch = new k8s.Watch(this._kubeConfig); 43 | return watch.watch( 44 | endpoint, 45 | {}, 46 | (type, obj) => { 47 | switch (type) { 48 | case 'ADDED': 49 | this.emit('updated', obj); 50 | break; 51 | case 'MODIFIED': 52 | this.emit('updated', obj); 53 | break; 54 | case 'DELETED': 55 | this.emit('deleted', obj); 56 | break; 57 | } 58 | }, 59 | (err) => { 60 | if (err) { 61 | this.emit('error', err); 62 | } else { 63 | watcherHangup.inc(); 64 | this._watch = this._makeWatch(endpoint); 65 | } 66 | } 67 | ); 68 | } 69 | 70 | close() { 71 | this.removeAllListeners(); 72 | this._watch.removeAllListeners(); 73 | this._watch.abort(); 74 | } 75 | } 76 | 77 | export default K8sWatcher; 78 | -------------------------------------------------------------------------------- /src/k8sConfig/annotationNames.ts: -------------------------------------------------------------------------------- 1 | export const HOST = 'kube-auth-proxy/host'; 2 | export const TARGET_PORT = 'kube-auth-proxy/targetPort'; 3 | export const PROTOCOL = 'kube-auth-proxy/protocol'; 4 | export const VALIDATE_CERTIFICATE = 'kube-auth-proxy/validateCertificate'; 5 | export const BEARER_TOKEN_SECRET = 'kube-auth-proxy/bearerTokenSecret'; 6 | export const BASIC_AUTH_USERNAME = 'kube-auth-proxy/basicAuthUsername'; 7 | export const BASIC_AUTH_PASSWORD = 'kube-auth-proxy/basicAuthPassword'; 8 | export const BASIC_AUTH_PASSWORD_SECRET = 'kube-auth-proxy/basicAuthPasswordSecret'; 9 | export const GITHUB_ALLOWED_ORGS = 'kube-auth-proxy/githubAllowedOrganizations'; 10 | export const GITHUB_ALLOWED_TEAMS = 'kube-auth-proxy/githubAllowedTeams'; 11 | export const GITHUB_ALLOWED_USERS = 'kube-auth-proxy/githubAllowedUsers'; 12 | export const ALLOWED_EMAILS = 'kube-auth-proxy/allowedEmails'; 13 | export const EMAIL_DOMAINS = 'kube-auth-proxy/emailDomains'; 14 | -------------------------------------------------------------------------------- /src/k8sConfig/k8sUtils.ts: -------------------------------------------------------------------------------- 1 | import * as k8s from '@kubernetes/client-node'; 2 | 3 | export type K8sSecretSpecifier = 4 | | { 5 | namespace?: string; 6 | secretName: string; 7 | dataName: string; 8 | } 9 | | { 10 | namespace?: string; 11 | secretRegex: RegExp | string; 12 | dataName: string; 13 | }; 14 | 15 | /** 16 | * Parse and validate a "secret specificier". 17 | * 18 | * @param spec - the specifier to parse. 19 | * @param source - the location where this specifier was read from (used in error messages). 20 | */ 21 | export function parseSecretSpecifier(defaultNamespace: string, spec: string, source: string) { 22 | let secretSpec: any; 23 | try { 24 | secretSpec = JSON.parse(spec); 25 | } catch (err) { 26 | throw new Error(`Could not parse ${spec} in ${source}`); 27 | } 28 | 29 | if (!secretSpec.namespace) { 30 | secretSpec.namespace = defaultNamespace; 31 | } 32 | 33 | if (!secretSpec.secretName && !secretSpec.secretRegex) { 34 | throw new Error(`Missing secretName in ${source}`); 35 | } 36 | 37 | if (typeof secretSpec.secretRegex === 'string') { 38 | try { 39 | secretSpec.secretRegex = new RegExp(secretSpec.secretRegex); 40 | } catch (err) { 41 | throw new Error(`Invalid secretRegex in ${source}: ${err}`); 42 | } 43 | } else if (secretSpec.secretRegex instanceof RegExp) { 44 | // OK 45 | } else { 46 | throw new Error(`Invalid secretRegex in ${source}`); 47 | } 48 | 49 | if (!secretSpec.dataName) { 50 | throw new Error(`Missing dataName in ${source}`); 51 | } 52 | return secretSpec as K8sSecretSpecifier; 53 | } 54 | 55 | /** 56 | * Get the value of a secret from Kubernetes. 57 | */ 58 | export async function readSecret( 59 | k8sApi: k8s.CoreV1Api, 60 | secret: K8sSecretSpecifier, 61 | defaultNamespace?: string 62 | ) { 63 | const name = `${secret.namespace}/${ 64 | 'secretName' in secret ? secret.secretName : secret.secretRegex 65 | }`; 66 | 67 | let secretObj: k8s.V1Secret; 68 | if ('secretName' in secret) { 69 | secretObj = await getSecret( 70 | k8sApi, 71 | secret.namespace || defaultNamespace || 'default', 72 | secret.secretName 73 | ); 74 | } else { 75 | let regex = secret.secretRegex; 76 | if (typeof regex === 'string') { 77 | regex = new RegExp(regex); 78 | } 79 | secretObj = await getSecretFromRegex( 80 | k8sApi, 81 | secret.namespace || defaultNamespace || 'default', 82 | regex 83 | ); 84 | } 85 | 86 | const base64Data = secretObj.data?.[secret.dataName]; 87 | if (!base64Data) { 88 | throw new Error(`Secret ${name} has no data named ${secret.dataName}`); 89 | } 90 | return decodeSecret(base64Data); 91 | } 92 | 93 | export function decodeSecret(base64Data: string) { 94 | return Buffer.from(base64Data, 'base64').toString('utf-8'); 95 | } 96 | 97 | function getSecret(k8sApi: k8s.CoreV1Api, namespace: string, secretName: string) { 98 | return k8sApi 99 | .readNamespacedSecret(secretName, namespace) 100 | .then((response) => response.body) 101 | .catch((err) => { 102 | if (err?.response?.statusCode === 404) { 103 | throw new Error(`Secret ${name} not found.`); 104 | } else { 105 | throw new Error(`Error fetching secret ${name} from Kubernetes.`); 106 | } 107 | }); 108 | } 109 | 110 | async function getSecretFromRegex(k8sApi: k8s.CoreV1Api, namespace: string, secretRegex: RegExp) { 111 | const secrets = await k8sApi.listNamespacedSecret(namespace); 112 | for (const secret of secrets.body.items) { 113 | if (secret.metadata?.name && secretRegex.test(secret.metadata.name)) { 114 | return secret; 115 | } 116 | } 117 | 118 | throw new Error( 119 | `Could not find secret in namespace ${namespace} matching regex ${secretRegex}` 120 | ); 121 | } 122 | 123 | /** 124 | * Convert a label selector into query parameters. 125 | */ 126 | export function labelSelectorToQueryParam(labelSelector: k8s.V1LabelSelector | undefined) { 127 | if (!labelSelector) { 128 | return ''; 129 | } 130 | 131 | const filters: string[] = []; 132 | if (labelSelector.matchLabels) { 133 | for (const key of Object.keys(labelSelector.matchLabels)) { 134 | filters.push(`${key}=${labelSelector.matchLabels[key]}`); 135 | } 136 | } 137 | 138 | if (labelSelector.matchExpressions) { 139 | for (const expression of labelSelector.matchExpressions) { 140 | const operator = expression.operator.toLowerCase(); 141 | if (operator === 'in' || operator === 'notin') { 142 | filters.push( 143 | `${expression.key} ${operator} (${(expression.values || [])?.join(', ')})` 144 | ); 145 | } else if (operator === 'exists') { 146 | filters.push(expression.key); 147 | } else if (operator === 'doesnotexist') { 148 | filters.push(`!${expression.key}`); 149 | } else { 150 | throw new Error(`Unknown operator ${expression.operator}`); 151 | } 152 | } 153 | } 154 | 155 | const value = filters.join(','); 156 | const query = new URLSearchParams(); 157 | query.set('labelSelector', value); 158 | 159 | return `?${query.toString()}`; 160 | } 161 | -------------------------------------------------------------------------------- /src/k8sConfig/types.ts: -------------------------------------------------------------------------------- 1 | import * as k8s from '@kubernetes/client-node'; 2 | import { RawProxyTarget } from '../targets'; 3 | 4 | export interface ProxyTargetCrd { 5 | apiVersion: string; 6 | kind: 'ProxyTarget'; 7 | metadata?: k8s.V1ObjectMeta; 8 | target: RawProxyTarget; 9 | } 10 | -------------------------------------------------------------------------------- /src/metrics.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import prometheus from 'prom-client'; 5 | import * as log from './utils/logger'; 6 | 7 | // Create a `version` gauge. This gauge always has a value of 1 - the 8 | // interesting stuff is in the gauge's labels. You can do a PromQL query 9 | // like `count by(gitCommit) (loop_build_info)` to get the a count of the 10 | // distinct loop versions deployed to the cluster (which should ideally be 11 | // 1, unless we're in the middle of an upgrade). 12 | const version = new prometheus.Gauge({ 13 | name: `kube_auth_proxy_build_info`, 14 | help: 'kube-auth-proxy version info.', 15 | labelNames: ['version'], 16 | }); 17 | 18 | function metricsEndpoint() { 19 | return (_req: express.Request, res: express.Response) => { 20 | res.writeHead(200, { 'Content-Type': 'text/plain' }); 21 | prometheus.register.metrics().then((body) => { 22 | res.end(body); 23 | }); 24 | }; 25 | } 26 | 27 | /** 28 | * Starts a prometheus-style metrics server on the specified port. 29 | */ 30 | export function startMetricsServer(metricsPort: number) { 31 | const PKG_DATA = JSON.parse( 32 | fs.readFileSync(path.resolve(__dirname, '../package.json'), { encoding: 'utf-8' }) 33 | ); 34 | log.info(`Running version v${PKG_DATA.version}`); 35 | version.set( 36 | { 37 | version: `v${PKG_DATA.version}`, 38 | }, 39 | 1 40 | ); 41 | 42 | const app = express(); 43 | app.get('/metrics', metricsEndpoint()); 44 | app.listen(metricsPort, () => { 45 | log.info(`Metrics server available on http://localhost:${metricsPort}/metrics`); 46 | }); 47 | 48 | return app; 49 | } 50 | 51 | export const connectionErrorCount = new prometheus.Counter({ 52 | name: 'kube_auth_proxy_connection_error', 53 | help: 'Connection was terminated due to a protocol error.', 54 | labelNames: ['type'], 55 | }); 56 | 57 | export const forwardCount = new prometheus.Counter({ 58 | name: 'kube_auth_proxy_forwarded', 59 | help: 'A count of the number of requests forwarded.', 60 | labelNames: ['type'], 61 | }); 62 | 63 | export const notAuthorizedCount = new prometheus.Counter({ 64 | name: 'kube_auth_proxy_not_authorized', 65 | help: 66 | 'A count of the number of requests not forwarded,' + 'because the user was not authorized.', 67 | labelNames: ['type'], 68 | }); 69 | 70 | export const notAuthenticatedCount = new prometheus.Counter({ 71 | name: 'kube_auth_proxy_not_authenticated', 72 | help: 73 | 'A count of the number of requests not forwarded,' + 74 | 'because the user was not authenticated.', 75 | labelNames: ['type'], 76 | }); 77 | 78 | export const noTargetFound = new prometheus.Counter({ 79 | name: 'kube_auth_proxy_no_target_found', 80 | help: 81 | 'A count of the number of requests not forwarded,' + 82 | 'because there was no destination configured.', 83 | labelNames: ['type'], 84 | }); 85 | 86 | export const backendErrorCount = new prometheus.Counter({ 87 | name: 'kube_auth_proxy_backend_error', 88 | help: 89 | 'A count of the number of requests forwarded, but which had ' + 90 | 'an error connecting to the backend service.', 91 | labelNames: ['type'], 92 | }); 93 | -------------------------------------------------------------------------------- /src/server/authentication.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { Passport } from 'passport'; 3 | import { AuthModule } from '../authModules/AuthModule'; 4 | import { notAuthenticatedCount } from '../metrics'; 5 | import { SanitizedKubeAuthProxyConfig } from '../types'; 6 | import { loginScreen } from '../ui/loginScreen'; 7 | import * as log from '../utils/logger'; 8 | import './express-types'; 9 | 10 | /** 11 | * Returns an express-style `function(req, res, next)` which will handle the 12 | * request if the user is not logged in, and will pass the requets through 13 | * otherwise. 14 | */ 15 | export default function authentication( 16 | config: SanitizedKubeAuthProxyConfig, 17 | authModules: AuthModule[] 18 | ) { 19 | const passport = new Passport(); 20 | 21 | // Whatever user the "AuthModule" gives us, just write it directly into the session. 22 | passport.serializeUser((user: any, done) => { 23 | // Verify users have a "type". 24 | if (user.type) { 25 | done(null, user); 26 | } else { 27 | done('pass'); 28 | } 29 | }); 30 | passport.deserializeUser((user: any, done) => done(null, user)); 31 | 32 | const router = express.Router(); 33 | 34 | router.use(passport.initialize()); 35 | router.use(passport.session()); 36 | 37 | for (const mod of authModules) { 38 | router.use(mod.authenticationMiddleware(config, passport)); 39 | } 40 | 41 | router.get('/kube-auth-proxy/logout', (req, res) => { 42 | if (req.user) { 43 | log.info(`Logged out user ${req.user.username}@${req.user.type}`); 44 | } 45 | req.logout(); 46 | res.redirect('/'); 47 | }); 48 | 49 | router.get('/kube-auth-proxy/login', (req, res) => { 50 | const redirect = req.query.redirect || '/'; 51 | const redirectUrl = typeof redirect === 'string' ? redirect : '/'; 52 | 53 | //; TODO: Could probably come up with a prettier login screen. :P 54 | res.set('content-type', 'text/html'); 55 | res.end(loginScreen({ authModules, redirectUrl })); 56 | }); 57 | 58 | router.use((req, res, next) => { 59 | // If there's an authenticated user, forward this along. Otherwise, need to return the login screen. 60 | if (req.user) { 61 | log.debug(`authentication: Found user ${req.user.username}@${req.user.type}`); 62 | next(); 63 | } else { 64 | notAuthenticatedCount.inc({ type: 'http' }); 65 | 66 | /* 67 | * Note that we could return a 302 here and redirect to 68 | * `/kube-auth-proxy/login?redirect=...`. Instead we return 69 | * a 401 here. 70 | * 71 | * Some clients will run into problems if we return a 302 here, 72 | * if they automatically follow redirects. Consider being 73 | * logged in to some service, leave the window open for a bit, 74 | * then kube-auth-proxy automatically logs you out. You come 75 | * back to this existing window, and click on a button that 76 | * results in an AJAX request. If the client code is using 77 | * the WHAT-WG fetch API, they'll probably automatically 78 | * follow the 302 to the login page (which will be a 200), 79 | * and then try to pass the login page through `JSON.parse()`, 80 | * which ends in sad faces all around. By returning a 401 81 | * error here, we make it more likely the client will do 82 | * something sensible in this situation. 83 | */ 84 | log.debug( 85 | `Returning login screen for unauthenticated connection to ${req.headers.host}` 86 | ); 87 | 88 | const redirectUrl = `${req.protocol}://${req.headers.host}${req.originalUrl}`; 89 | 90 | res.status(401) 91 | .set('WWW-Authenticate', 'type=OAuth') 92 | .end(loginScreen({ authModules, redirectUrl })); 93 | } 94 | }); 95 | 96 | return router; 97 | } 98 | -------------------------------------------------------------------------------- /src/server/authorization.ts: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | import { notAuthorizedCount } from '../metrics'; 3 | import { CompiledProxyTarget } from '../targets'; 4 | import { authorizeUserForTarget } from '../targets/authorization'; 5 | 6 | export function authorizationMiddleware() { 7 | const mw = wsAuthorizationMiddleware(); 8 | return (req: http.IncomingMessage, _res: any, next: (err?: Error) => void) => { 9 | mw(req, next); 10 | }; 11 | } 12 | 13 | export function wsAuthorizationMiddleware() { 14 | return function authorization(req: http.IncomingMessage, next: (err?: Error) => void) { 15 | const { user, target } = req as { user?: Express.User; target?: CompiledProxyTarget }; 16 | if (!user) { 17 | throw new Error("Attempted to authorize a user, but there's no user."); 18 | } 19 | if (!target) { 20 | throw new Error('No target in request.'); 21 | } 22 | 23 | const authorized = authorizeUserForTarget(user, target); 24 | if (!authorized) { 25 | notAuthorizedCount.inc({ type: 'http' }); 26 | const error = new Error('Not authorized'); 27 | (error as any).status = 403; 28 | next(error); 29 | } else { 30 | next(); 31 | } 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /src/server/express-types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-namespace */ 2 | import { CompiledProxyTarget } from '../targets'; 3 | import { KubeAuthProxyUser } from '../types'; 4 | 5 | declare global { 6 | namespace Express { 7 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 8 | interface User extends KubeAuthProxyUser {} 9 | } 10 | } 11 | 12 | declare module 'express-serve-static-core' { 13 | interface Request

{ 14 | target?: CompiledProxyTarget; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/server/findTarget.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { noTargetFound } from '../metrics'; 3 | import { CompiledProxyTarget } from '../targets'; 4 | import { KubeAuthProxyUser } from '../types'; 5 | import * as log from '../utils/logger'; 6 | import { targetList } from '../ui/targetList'; 7 | 8 | export interface ProxyTargetFinder { 9 | findTarget(host: string): CompiledProxyTarget | undefined; 10 | findTargetsForUser(user: KubeAuthProxyUser): CompiledProxyTarget[]; 11 | } 12 | 13 | /** 14 | * Creates a proxy which forwards connections based on configuration in `proxyTargets`. 15 | * 16 | * Whenever a connection comes in, the request's host will be looked up in 17 | * `proxyTargets`. If a match is found, the request will be forwarded. 18 | */ 19 | export function findTargetMiddleware( 20 | proxyTargets: ProxyTargetFinder, 21 | domain: string 22 | ): express.RequestHandler { 23 | return (req, res, next) => { 24 | const host = req.headers.host; 25 | const proxyTarget = proxyTargets.findTarget(host || ''); 26 | 27 | if (!proxyTarget) { 28 | noTargetFound.inc({ type: 'http' }); 29 | log.info(`Rejecting http connection for service ${host}.`); 30 | 31 | let response: string; 32 | if (!req.user) { 33 | // This should never happen 34 | response = 'Not found'; 35 | } else { 36 | const targets = proxyTargets.findTargetsForUser(req.user); 37 | response = targetList({ 38 | user: req.user, 39 | domain, 40 | targets, 41 | }); 42 | } 43 | 44 | res.statusCode = 404; 45 | res.send(response); 46 | return; 47 | } 48 | 49 | req.target = proxyTarget; 50 | next(); 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /src/server/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import http from 'http'; 3 | import { AuthModule } from '../authModules/AuthModule'; 4 | import { backendErrorCount } from '../metrics'; 5 | import { SanitizedKubeAuthProxyConfig } from '../types'; 6 | import { targetList } from '../ui/targetList'; 7 | import * as log from '../utils/logger'; 8 | import { getServerPort } from '../utils/server'; 9 | import authentication from './authentication'; 10 | import { authorizationMiddleware } from './authorization'; 11 | import { findTargetMiddleware, ProxyTargetFinder } from './findTarget'; 12 | import proxy from './proxy'; 13 | import { sessionMiddleware } from './session'; 14 | import { makeWebsocketHandler } from './websocket'; 15 | 16 | export function startServer( 17 | config: SanitizedKubeAuthProxyConfig, 18 | proxyTargets: ProxyTargetFinder, 19 | authModules: AuthModule[] 20 | ) { 21 | const app = express(); 22 | app.disable('x-powered-by'); 23 | app.enable('trust proxy'); 24 | 25 | app.get('/kube-auth-proxy/status', (_req, res) => { 26 | res.send('ok'); 27 | }); 28 | 29 | app.use(sessionMiddleware(config)); 30 | 31 | // Do authentication before searching for the target - this way 32 | // we redirect to the login screen even for domains that don't exist, 33 | // and attackers can't probe what domains do or do not exist. 34 | app.use(authentication(config, authModules)); 35 | 36 | app.get('/kube-auth-proxy/list', (req, res, next) => { 37 | if (!req.user) { 38 | return next(); 39 | } 40 | const targets = proxyTargets.findTargetsForUser(req.user); 41 | res.send( 42 | targetList({ 43 | user: req.user, 44 | domain: config.domain, 45 | targets, 46 | }) 47 | ); 48 | }); 49 | 50 | // This sets `req.target`. 51 | app.use(findTargetMiddleware(proxyTargets, config.domain)); 52 | app.use(authorizationMiddleware()); 53 | app.use(proxy()); 54 | 55 | app.use( 56 | (err: Error, req: express.Request, res: express.Response, _next: express.NextFunction) => { 57 | if ((err as any).status) { 58 | res.statusCode = (err as any).status; 59 | res.end(err.message); 60 | } else { 61 | backendErrorCount.inc({ type: 'http' }); 62 | if ((err as any).code !== 'ECONNREFUSED') { 63 | log.error( 64 | err, 65 | `Error forwarding connection to ${req.headers.host}${req.url}: ${ 66 | (err as any).code 67 | }` 68 | ); 69 | res.statusCode = 502; 70 | res.end('Bad Gateway'); 71 | } else { 72 | res.statusCode = 500; 73 | res.end('Internal server error'); 74 | } 75 | } 76 | } 77 | ); 78 | 79 | const server = http.createServer(app); 80 | 81 | // Handle proxying websocket connections. 82 | server.on('upgrade', makeWebsocketHandler(config, proxyTargets)); 83 | 84 | server.listen(config.port); 85 | 86 | server.on('listening', () => { 87 | const port = getServerPort(server); 88 | log.info(`Listening on port ${port}`); 89 | }); 90 | 91 | return server; 92 | } 93 | -------------------------------------------------------------------------------- /src/server/proxy.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import httpProxy from 'http-proxy'; 3 | import { forwardCount } from '../metrics'; 4 | import * as log from '../utils/logger'; 5 | 6 | /** 7 | * Creates a proxy which forwards connections based on configuration in `proxyTargets`. 8 | * 9 | * Whenever a connection comes in, the request's host will be looked up in 10 | * `proxyTargets`. If a match is found, the request will be forwarded. 11 | */ 12 | export default function proxyMiddleware(): express.RequestHandler { 13 | const proxy = httpProxy.createProxyServer({}); 14 | 15 | return (req, res, next) => { 16 | const proxyTarget = req.target; 17 | 18 | /* istanbul ignore next */ 19 | if (!proxyTarget) { 20 | next(new Error('No proxyTarget.')); 21 | return; 22 | } 23 | 24 | log.debug(`Forwarding request to ${proxyTarget.targetUrl}`); 25 | forwardCount.inc({ type: 'http' }); 26 | 27 | if (proxyTarget.headers) { 28 | req.headers = { 29 | ...req.headers, 30 | ...proxyTarget.headers, 31 | }; 32 | } 33 | 34 | proxy.web( 35 | req, 36 | res, 37 | { target: proxyTarget.targetUrl, secure: proxyTarget.validateCertificate }, 38 | (err) => { 39 | if (err) { 40 | next(err); 41 | } 42 | } 43 | ); 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /src/server/session.ts: -------------------------------------------------------------------------------- 1 | import clientSessions from 'client-sessions'; 2 | import Cookies from 'cookies'; 3 | import http from 'http'; 4 | import { SanitizedKubeAuthProxyConfig } from '../types'; 5 | import express from 'express'; 6 | 7 | /** 8 | * Returns an express-style `function(req, res, next)` which will handle the 9 | * request if the user is not logged in, and will pass the requets through 10 | * otherwise. 11 | */ 12 | export function sessionMiddleware(config: SanitizedKubeAuthProxyConfig): express.RequestHandler { 13 | const clientSessionsMiddleware = clientSessions(getClientSessionOpts(config)); 14 | return (req, res, next) => { 15 | if (req.secure || req.headers['x-forwarded-proto'] === 'https') { 16 | // Workaround for http://github.com/mozilla/node-client-sessions/issues/101 17 | (req.connection as any).proxySecure = true; 18 | } 19 | clientSessionsMiddleware(req, res, next); 20 | }; 21 | } 22 | 23 | export function wsSessionMiddleware(config: SanitizedKubeAuthProxyConfig) { 24 | const opts = getClientSessionOpts(config); 25 | 26 | return function session(req: http.IncomingMessage, next: (err?: Error) => void) { 27 | let done = false; 28 | 29 | if (req.headers['x-forwarded-proto'] === 'https') { 30 | // Workaround for http://github.com/mozilla/node-client-sessions/issues/101 31 | (req.connection as any).proxySecure = true; 32 | } 33 | 34 | try { 35 | const cookies = new Cookies(req, {} as any); 36 | const cookieData = cookies.get(config.sessionCookieName); 37 | if (cookieData) { 38 | const sessionData = clientSessions.util.decode(opts, cookieData); 39 | (req as any).session = sessionData?.content; 40 | } 41 | done = true; 42 | next(); 43 | } catch (err) { 44 | if (!done) { 45 | next(err as Error); 46 | } 47 | } 48 | }; 49 | } 50 | 51 | function getClientSessionOpts(config: SanitizedKubeAuthProxyConfig) { 52 | const cookieDomain = config.domain.startsWith('localhost:') ? 'localhost' : config.domain; 53 | 54 | return { 55 | cookieName: config.sessionCookieName, 56 | requestKey: 'session', 57 | secret: config.sessionSecret, 58 | duration: 24 * 60 * 60 * 1000, 59 | activeDuration: 1000 * 60 * 5, 60 | cookie: { 61 | domain: cookieDomain, 62 | secure: config.secureCookies, 63 | httpOnly: true, 64 | }, 65 | }; 66 | } 67 | -------------------------------------------------------------------------------- /src/server/websocket.ts: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | import httpProxy from 'http-proxy'; 3 | import net from 'net'; 4 | import * as metrics from '../metrics'; 5 | import { SanitizedKubeAuthProxyConfig } from '../types'; 6 | import * as log from '../utils/logger'; 7 | import { generateHttpMessage } from '../utils/utils'; 8 | import { wsAuthorizationMiddleware } from './authorization'; 9 | import { ProxyTargetFinder } from './findTarget'; 10 | import { wsSessionMiddleware } from './session'; 11 | 12 | export function makeWebsocketHandler( 13 | config: SanitizedKubeAuthProxyConfig, 14 | proxyTargets: ProxyTargetFinder 15 | ) { 16 | const session = wsSessionMiddleware(config); 17 | const authorization = wsAuthorizationMiddleware(); 18 | const proxy = httpProxy.createProxyServer({}); 19 | 20 | return (req: http.IncomingMessage, socket: net.Socket, head: any) => { 21 | session(req, (err) => { 22 | if (err) { 23 | log.error(err, 'Error reading session for websocket connection.'); 24 | metrics.connectionErrorCount.inc({ type: 'ws' }); 25 | socket.end(generateHttpMessage(500, 'Internal Server Error')); 26 | return; 27 | } 28 | 29 | const user = ((req as any).user = (req as any).session?.passport?.user); 30 | const host = req.headers.host || ''; 31 | 32 | if (!(req as any).user) { 33 | metrics.notAuthenticatedCount.inc({ type: 'ws' }); 34 | log.debug(`Rejecting unauthenticated websocket connection to ${host}.`); 35 | socket.end(generateHttpMessage(401, 'Unauthorized')); 36 | return; 37 | } 38 | 39 | const target = proxyTargets.findTarget(host); 40 | if (!target) { 41 | metrics.noTargetFound.inc({ type: 'ws' }); 42 | log.info(`Rejecting websocket connection for service ${host}.`); 43 | 44 | socket.end(generateHttpMessage(404, 'Not found')); 45 | return; 46 | } 47 | (req as any).target = target; 48 | 49 | authorization(req, (err) => { 50 | if (err) { 51 | log.info( 52 | `Rejecting unauthorized user ${user.username} for service ${target.host}.` 53 | ); 54 | metrics.notAuthorizedCount.inc({ type: 'ws' }); 55 | socket.end(generateHttpMessage(403, 'Forbidden')); 56 | return; 57 | } 58 | 59 | log.debug( 60 | `Forwarding WS connection from user ${user.username} to service ${target.host}.` 61 | ); 62 | metrics.forwardCount.inc({ type: 'ws' }); 63 | 64 | if (target.headers) { 65 | req.headers = { 66 | ...req.headers, 67 | ...target.headers, 68 | }; 69 | } 70 | 71 | proxy.ws(req, socket, head, { target: target.wsTargetUrl }, (err) => { 72 | if (err) { 73 | metrics.backendErrorCount.inc({ type: 'ws' }); 74 | if ((err as any).code !== 'ECONNREFUSED') { 75 | log.error(err, 'Error forwarding websocket connection'); 76 | } 77 | socket.end(generateHttpMessage(500, 'Internal Error')); 78 | } 79 | }); 80 | }); 81 | }); 82 | }; 83 | } 84 | -------------------------------------------------------------------------------- /src/targets/authorization.ts: -------------------------------------------------------------------------------- 1 | import { CompiledProxyTarget } from '.'; 2 | import { GithubUser } from '../authModules/github'; 3 | import { Condition, KubeAuthProxyUser } from '../types'; 4 | import { intersectionNotEmpty } from '../utils/utils'; 5 | 6 | /** 7 | * Returns true if the given user is authorized to access the given target. 8 | * 9 | * @param authModules - List of enabled authentication modules. 10 | * @param user - The user to check. 11 | * @param target - the target to check. 12 | */ 13 | export function authorizeUserForTarget(user: KubeAuthProxyUser, target: CompiledProxyTarget) { 14 | let authorized = false; 15 | if (target.conditions.length === 0) { 16 | authorized = true; 17 | } else { 18 | authorized = target.conditions.some( 19 | (condition) => authorizeEmails(user, condition) && authorizeGithub(user, condition) 20 | ); 21 | } 22 | 23 | return authorized; 24 | } 25 | 26 | /** 27 | * Returns true if the given user satisfies the `emailDomains` and `allowedEmails` 28 | * parts of the given condition. 29 | */ 30 | function authorizeEmails(user: KubeAuthProxyUser, condition: Condition) { 31 | const matchAllowedEmails = 32 | !condition.allowedEmails || intersectionNotEmpty(condition.allowedEmails, user.emails); 33 | 34 | const matchEmailDomains = 35 | !condition.emailDomains || 36 | user.emails.some((email) => 37 | condition.emailDomains?.some((domain) => email.endsWith(domain)) 38 | ); 39 | 40 | return matchAllowedEmails && matchEmailDomains; 41 | } 42 | 43 | /** 44 | * Returns true if the given user satisfies the github parts of the given condition. 45 | */ 46 | function authorizeGithub(user: Express.User, condition: Condition): boolean { 47 | const githubUser = user.type === 'github' ? (user as GithubUser) : false; 48 | 49 | let answer = true; 50 | const { githubAllowedUsers, githubAllowedOrganizations, githubAllowedTeams } = condition; 51 | 52 | if (githubAllowedUsers) { 53 | answer = answer && githubUser && githubAllowedUsers.includes(user.username); 54 | } 55 | if (githubAllowedTeams) { 56 | answer = answer && githubUser && intersectionNotEmpty(githubAllowedTeams, githubUser.teams); 57 | } 58 | if (githubAllowedOrganizations) { 59 | answer = 60 | answer && 61 | githubUser && 62 | intersectionNotEmpty(githubAllowedOrganizations, githubUser.orgs); 63 | } 64 | 65 | return answer; 66 | } 67 | -------------------------------------------------------------------------------- /src/targets/index.ts: -------------------------------------------------------------------------------- 1 | import * as k8s from '@kubernetes/client-node'; 2 | import _ from 'lodash'; 3 | import { URL } from 'url'; 4 | import { K8sSecretSpecifier, readSecret } from '../k8sConfig/k8sUtils'; 5 | import { Condition, RawCondition } from '../types'; 6 | import * as log from '../utils/logger'; 7 | 8 | interface ServiceNameTargetSpecifier { 9 | namespace?: string; 10 | service: string; 11 | targetPort: string | number; 12 | protocol?: 'http' | 'https'; 13 | validateCertificate?: boolean; 14 | } 15 | 16 | export type TargetSpecifier = 17 | | ServiceNameTargetSpecifier 18 | | { 19 | service: k8s.V1Service; 20 | targetPort: string | number; 21 | protocol?: 'http' | 'https'; 22 | validateCertificate?: boolean; 23 | } 24 | | { 25 | targetUrl: string; 26 | }; 27 | 28 | export type RawProxyTarget = { 29 | /** A key which uniquely identifies the "source" of the ProxyTarget. */ 30 | key: string; 31 | source: string; 32 | host: string; 33 | to: TargetSpecifier; 34 | bearerTokenSecret?: K8sSecretSpecifier; 35 | basicAuthUsername?: string; 36 | basicAuthPassword?: string; 37 | basicAuthPasswordSecret?: K8sSecretSpecifier; 38 | conditions?: RawCondition; 39 | }; 40 | 41 | export interface Headers { 42 | [header: string]: string | string[]; 43 | } 44 | 45 | export interface CompiledProxyTarget { 46 | compiled: true; 47 | /** 48 | * A key which uniquely identifies this ProxyTarget. 49 | */ 50 | key: string; 51 | /** 52 | * A key which uniquely identifies the source of the ProxyTarget. 53 | * Note that multiple targets may have the same source if they came from the 54 | * same place (for example, many targets defined in a single 55 | * configmap). 56 | */ 57 | source: string; 58 | /** The target endpoint to forward http traffic to. */ 59 | targetUrl: string; 60 | /** The target endpoint to forward websocket traffic to. */ 61 | wsTargetUrl: string; 62 | /** 63 | * kube-auth-proxy will forward traffic to this endpoint if the "host" 64 | * header in the request is `${host}.${domain}` or is this string. 65 | */ 66 | host: string; 67 | /** User must match one of the given conditions to be allowed access. */ 68 | conditions: Condition[]; 69 | validateCertificate: boolean; 70 | /** A list of headers to add to requests sent to this target. */ 71 | headers?: Headers; 72 | } 73 | 74 | export function isServiceNameTargetSpecifier( 75 | target: TargetSpecifier 76 | ): target is ServiceNameTargetSpecifier { 77 | return 'service' in target && typeof target.service === 'string'; 78 | } 79 | 80 | export async function compileProxyTarget( 81 | k8sApi: k8s.CoreV1Api | undefined, 82 | target: RawProxyTarget, 83 | defaultConditions: Condition[], 84 | options: { 85 | defaultNamespace?: string; 86 | } = {} 87 | ): Promise { 88 | let targetUrl: string; 89 | const { to } = target; 90 | let defaultNamespace = options.defaultNamespace; 91 | let validateCertificate = true; 92 | 93 | if ('targetUrl' in to && typeof to.targetUrl === 'string') { 94 | targetUrl = to.targetUrl; 95 | } else if (isServiceNameTargetSpecifier(to)) { 96 | if (!k8sApi) { 97 | throw new Error(`Can't load service ${to.service} without kubernetes.`); 98 | } 99 | const namespace = to.namespace || 'default'; 100 | 101 | const service = await k8sApi.readNamespacedService(to.service, namespace); 102 | if (!service || !service.body) { 103 | throw new Error(`Can't find service ${namespace}/${to.service}`); 104 | } 105 | targetUrl = getTargetUrlFromService(service.body, to.targetPort, to.protocol ?? 'http'); 106 | defaultNamespace = defaultNamespace || namespace; 107 | validateCertificate = to.validateCertificate ?? true; 108 | } else if ('service' in to && typeof to.service !== 'string') { 109 | targetUrl = getTargetUrlFromService(to.service, to.targetPort, to.protocol ?? 'http'); 110 | defaultNamespace = defaultNamespace || to.service.metadata?.namespace; 111 | validateCertificate = to.validateCertificate ?? true; 112 | } else { 113 | throw new Error( 114 | `Need one of target.to.targetUrl or target.to.service in ${JSON.stringify(target)}` 115 | ); 116 | } 117 | 118 | const url = new URL(targetUrl); 119 | url.protocol = url.protocol === 'https' ? 'wss' : 'ws'; 120 | const wsTargetUrl = url.toString(); 121 | 122 | let headers: Headers | undefined; 123 | 124 | const bearerToken = await readSecretOrString(k8sApi, { 125 | secret: target.bearerTokenSecret, 126 | defaultNamespace, 127 | }); 128 | if (bearerToken) { 129 | headers = addHeader(headers, 'authorization', `Bearer ${bearerToken}`); 130 | } 131 | 132 | const username = target.basicAuthUsername; 133 | const password = await readSecretOrString(k8sApi, { 134 | secret: target.basicAuthPasswordSecret, 135 | value: target.basicAuthPassword, 136 | defaultNamespace, 137 | }); 138 | if (username && password) { 139 | const basicAuth = Buffer.from(`${username}:${password}`).toString('base64'); 140 | headers = addHeader(headers, 'authorization', `Basic ${basicAuth}`); 141 | } 142 | 143 | const answer: CompiledProxyTarget = { 144 | compiled: true, 145 | key: target.key, 146 | source: target.source, 147 | targetUrl, 148 | wsTargetUrl, 149 | host: target.host, 150 | conditions: getConditions(target.conditions, defaultConditions), 151 | validateCertificate, 152 | headers, 153 | }; 154 | 155 | return answer; 156 | } 157 | 158 | export function parseTargetsFromFile( 159 | namespace: string | undefined, 160 | source: string, 161 | filename: string, 162 | targets: RawProxyTarget[] | undefined 163 | ) { 164 | if (!targets || !Array.isArray(targets)) { 165 | log.warn(`${source}/${filename}: has no targets.`); 166 | return []; 167 | } 168 | 169 | const uniqueTargets = _.uniqBy(targets, (target) => target.host); 170 | if (uniqueTargets.length !== targets.length) { 171 | log.warn( 172 | `${source}/${filename} has multiple targets with the same host - some will be ignored.` 173 | ); 174 | } 175 | 176 | uniqueTargets.forEach((target) => { 177 | if (isServiceNameTargetSpecifier(target.to)) { 178 | target.to.namespace = target.to.namespace || namespace; 179 | } 180 | target.source = source; 181 | target.key = `${source}/${filename}/${target.host}`; 182 | }); 183 | return targets; 184 | } 185 | 186 | /** 187 | * Given a set of raw conditions from a service or config file, 188 | * generate a set of Condition objects. 189 | */ 190 | export function getConditions(target: RawCondition | undefined, defaultConditions: Condition[]) { 191 | const answer: Condition[] = []; 192 | 193 | const { 194 | allowedEmails, 195 | emailDomains, 196 | githubAllowedOrganizations, 197 | githubAllowedTeams, 198 | githubAllowedUsers, 199 | } = target || {}; 200 | 201 | if (allowedEmails) { 202 | answer.push({ allowedEmails }); 203 | } 204 | 205 | if (emailDomains) { 206 | answer.push({ 207 | emailDomains: emailDomains.map((domain) => 208 | domain.startsWith('@') ? domain : `@${domain}` 209 | ), 210 | }); 211 | } 212 | 213 | if (githubAllowedOrganizations) { 214 | answer.push({ githubAllowedOrganizations }); 215 | } 216 | 217 | if (githubAllowedTeams) { 218 | answer.push({ githubAllowedTeams }); 219 | } 220 | 221 | if (githubAllowedUsers) { 222 | answer.push({ githubAllowedUsers }); 223 | } 224 | 225 | return answer.length > 0 ? answer : defaultConditions; 226 | } 227 | 228 | /** 229 | * Given a target port name, find the actual port number from the service. 230 | */ 231 | function getTargetPortNumber(service: k8s.V1Service, targetPortName: string | number | undefined) { 232 | const { name: serviceName, namespace } = service.metadata || {}; 233 | let answer: number | undefined; 234 | 235 | if (typeof targetPortName === 'number') { 236 | answer = targetPortName; 237 | } else if (targetPortName) { 238 | const foundPortObj = 239 | service.spec?.ports && 240 | (service.spec.ports.find((port) => port.name === targetPortName) || 241 | service.spec.ports.find((port) => `${port.port}` === targetPortName)); 242 | 243 | if (foundPortObj) { 244 | answer = foundPortObj.port; 245 | } else { 246 | // Try to turn `targetPortName` into a number. 247 | const asNumber = parseInt(targetPortName, 10); 248 | if (!isNaN(asNumber)) { 249 | answer = asNumber; 250 | } else { 251 | throw new Error( 252 | `Can't find target port ${targetPortName ? `${targetPortName} ` : ''}` + 253 | `for service ${namespace}/${serviceName}` 254 | ); 255 | } 256 | } 257 | return answer; 258 | } else { 259 | const portObj = service.spec?.ports?.[0]; 260 | if (portObj) { 261 | answer = portObj.port; 262 | } 263 | } 264 | return answer; 265 | } 266 | 267 | function getTargetUrlFromService( 268 | service: k8s.V1Service, 269 | targetPort: string | number | undefined, 270 | protocol: 'http' | 'https' 271 | ) { 272 | const targetPortNumber = 273 | getTargetPortNumber(service, targetPort) || (protocol === 'http' ? 80 : 443); 274 | const { name, namespace } = service.metadata || {}; 275 | 276 | return `${protocol}://${name}.${namespace}:${targetPortNumber}`; 277 | } 278 | 279 | async function readSecretOrString( 280 | k8sApi: k8s.CoreV1Api | undefined, 281 | options: { 282 | secret?: K8sSecretSpecifier; 283 | value?: string; 284 | defaultNamespace?: string; 285 | } 286 | ) { 287 | if (options.value) { 288 | return options.value; 289 | } else if (options.secret) { 290 | if (!k8sApi) { 291 | throw new Error(`Can't specify secret without kubernetes.`); 292 | } 293 | return await readSecret(k8sApi, options.secret, options.defaultNamespace); 294 | } else { 295 | return undefined; 296 | } 297 | } 298 | 299 | function addHeader(headers: Headers | undefined, header: string, value: string) { 300 | const newHeaders = headers || {}; 301 | const existing = newHeaders[header]; 302 | if (!existing) { 303 | newHeaders[header] = value; 304 | } else if (typeof existing === 'string') { 305 | newHeaders[header] = [existing, value]; 306 | } else if (Array.isArray(existing)) { 307 | existing.push(value); 308 | } else { 309 | throw new Error(`Can't add header ${header} to request headers with value ${existing}`); 310 | } 311 | return newHeaders; 312 | } 313 | 314 | export function getFqdnForTarget(domain: string, target: CompiledProxyTarget) { 315 | return target.host.includes(':') || target.host.includes('.') 316 | ? target.host 317 | : `${target.host}.${domain}`; 318 | } 319 | 320 | /** 321 | * Converts a condition into a single line string. 322 | */ 323 | export function conditionToString(condition: Condition) { 324 | return `condition: ${JSON.stringify(condition)}`; 325 | } 326 | -------------------------------------------------------------------------------- /src/targets/validation.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import jsYaml from 'js-yaml'; 3 | import path from 'path'; 4 | import Ajv from 'ajv'; 5 | import { RawProxyTarget } from './index'; 6 | 7 | const PROXY_TARGET_CRD = jsYaml.load( 8 | fs.readFileSync(path.resolve(__dirname, '../../crds/kube-auth-proxy-proxy-target-crd.yaml'), { 9 | encoding: 'utf-8', 10 | }) 11 | ) as any; 12 | const PROXY_TARGET_SCHEMA = PROXY_TARGET_CRD.spec.validation.openAPIV3Schema; 13 | 14 | // Internally we add "key" and "source" right when we read the object. 15 | PROXY_TARGET_SCHEMA.properties.target.properties.key = { type: 'string' }; 16 | PROXY_TARGET_SCHEMA.properties.target.properties.source = { type: 'string' }; 17 | 18 | const ajv = new Ajv({ strict: true }); 19 | const proxyTargetValidator = ajv.compile(PROXY_TARGET_SCHEMA); 20 | 21 | export function validateProxyTarget(target: RawProxyTarget) { 22 | const valid = proxyTargetValidator({ target }); 23 | if (!valid) { 24 | let message: string | undefined; 25 | if (proxyTargetValidator.errors && proxyTargetValidator.errors.length === 1) { 26 | message = proxyTargetValidator.errors[0].message; 27 | } 28 | if (!message) { 29 | message = `\n${JSON.stringify(proxyTargetValidator.errors, null, 4)}`; 30 | } 31 | throw new Error(`Invalid ProxyTarget: ${message}`); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import * as k8s from '@kubernetes/client-node'; 2 | import { RawProxyTarget } from './targets'; 3 | import { LogLevel } from './utils/logger'; 4 | 5 | export interface KubeAuthProxyUser { 6 | /** 7 | * This is the authentication scheme that authorized this user (e.g. "github"). 8 | */ 9 | type: string; 10 | username: string; 11 | emails: string[]; 12 | } 13 | 14 | export interface RawKubeAuthProxyConfig { 15 | /** 16 | * The top-level domain name to proxy for. Used to set the domain 17 | * cookies are issued for. 18 | */ 19 | domain: string; 20 | 21 | /** 22 | * This is a domain name for kube-auth-proxy itself. Lots of 23 | * OAuth providers require a fixed callback URL, so this gives you 24 | * such a URL. Defaults to `auth.${domain}`. 25 | */ 26 | authDomain?: string; 27 | 28 | /** 29 | * A list of Kubernetes namespaces to watch. If omitted, will watch all 30 | * namespaces. 31 | */ 32 | namespaces?: string[]; 33 | 34 | /** 35 | * Port to listen on. If this is not specified in the config file, it 36 | * defaults to 5050. 37 | */ 38 | port?: number; 39 | 40 | /** 41 | * Port to start metrics server on. This will export prometheus style 42 | * metrics. DEfaults to 5051. 43 | */ 44 | metricsPort?: number; 45 | 46 | /** 47 | * Session cookie name. Defaults to "kube-auth-proxy". 48 | */ 49 | sessionCookieName?: string; 50 | 51 | /** 52 | * Session secret - this is required if you want to run kube-auth-proxy in 53 | * a cluster, or if you want your sessions to persist across reboots. 54 | */ 55 | sessionSecret?: string; 56 | 57 | /** 58 | * If true (the default) then the secure attribute will be set on the session 59 | * cookie. This must be disabled if you're using this without HTTPS. 60 | */ 61 | secureCookies?: boolean; 62 | 63 | auth?: { 64 | github?: { 65 | clientID: string; 66 | clientSecret: string; 67 | }; 68 | }; 69 | 70 | defaultConditions?: RawCondition; 71 | defaultTargets?: RawProxyTarget[]; 72 | 73 | logLevel?: LogLevel; 74 | 75 | proxyTargetSelector?: k8s.V1LabelSelector; 76 | } 77 | 78 | export type SanitizedKubeAuthProxyConfig = RawKubeAuthProxyConfig & { 79 | authDomain: string; 80 | sessionCookieName: string; 81 | sessionSecret: string; 82 | secureCookies: boolean; 83 | defaultConditions: Condition[]; 84 | defaultTargets: RawProxyTarget[]; 85 | }; 86 | 87 | export interface RawCondition { 88 | allowedEmails?: string[]; 89 | emailDomains?: string[]; 90 | githubAllowedOrganizations?: string[]; 91 | githubAllowedUsers?: string[]; 92 | githubAllowedTeams?: string[]; 93 | } 94 | 95 | export interface Condition { 96 | allowedEmails?: string[]; 97 | emailDomains?: string[]; 98 | githubAllowedOrganizations?: string[]; 99 | githubAllowedTeams?: string[]; 100 | githubAllowedUsers?: string[]; 101 | } 102 | -------------------------------------------------------------------------------- /src/ui/loginScreen.ts: -------------------------------------------------------------------------------- 1 | import { AuthModule } from '../authModules/AuthModule'; 2 | 3 | export function loginScreen(props: { authModules: AuthModule[]; redirectUrl: string }) { 4 | const { authModules, redirectUrl } = props; 5 | const loginButtons = authModules.map((mod) => mod.getLoginButton(redirectUrl)); 6 | 7 | return ` 8 | 9 | 10 | kube-auth-proxy 11 | 63 | 64 | 65 |

66 |
67 | Not Logged In 68 |
69 |
70 | 71 |
72 | ${loginButtons.join('\n')} 73 |
74 | 75 |
76 | Powered by kube-auth-proxy 77 |
78 | 79 | 80 | `; 81 | } 82 | -------------------------------------------------------------------------------- /src/ui/targetList.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { CompiledProxyTarget, getFqdnForTarget } from '../targets'; 3 | import { KubeAuthProxyUser } from '../types'; 4 | 5 | export function targetList(props: { 6 | user: KubeAuthProxyUser; 7 | domain: string; 8 | targets: CompiledProxyTarget[]; 9 | }) { 10 | const { user, domain } = props; 11 | const targets = _.sortBy(props.targets, (target) => target.host); 12 | const haveGlobalTargets = targets.some((t) => t.conditions.length === 0); 13 | 14 | return ` 15 | 16 | 17 | kube-auth-proxy 18 | 57 | 58 | 59 |
60 |
61 | ${user.username} [${user.type}] - logout 62 |
63 |
64 | 65 |
66 |

Available Services

67 |
    68 | ${targets.map((target) => renderTarget(domain, target)).join('\n')} 69 |
70 | 71 | ${ 72 | haveGlobalTargets 73 | ? `

* Warning: These targets have no authorization conditions, and can be accessed by anyone.

` 74 | : '' 75 | } 76 |
77 | 78 |
79 | Powered by kube-auth-proxy 80 |
81 | 82 | 83 | `; 84 | } 85 | 86 | function renderTarget(domain: string, target: CompiledProxyTarget) { 87 | const host = getFqdnForTarget(domain, target); 88 | const url = `https://${host}`; 89 | const anyUser = target.conditions.length === 0 ? ' *' : ''; 90 | 91 | return `
  • ${target.host}${anyUser}
  • `; 92 | } 93 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | import debugFormat from 'winston-format-debug'; 3 | 4 | export type LogLevel = 'debug' | 'info' | 'warning' | 'error'; 5 | 6 | const logger = winston.createLogger({ 7 | levels: winston.config.syslog.levels, 8 | level: 'info', 9 | transports: [ 10 | new winston.transports.Console({ 11 | format: winston.format.combine( 12 | debugFormat({ 13 | levels: winston.config.syslog.levels, 14 | colors: winston.config.syslog.colors, 15 | }) 16 | ), 17 | }), 18 | ], 19 | }); 20 | 21 | export function setLevel(level: LogLevel) { 22 | logger.level = level; 23 | } 24 | 25 | export function debug(message: string) { 26 | logger.log({ level: 'debug', message }); 27 | } 28 | 29 | export function info(message: string) { 30 | logger.log({ level: 'info', message }); 31 | } 32 | 33 | export function warn(message: string) { 34 | logger.log({ level: 'warning', message }); 35 | } 36 | 37 | export function error(err: Error, message?: string) { 38 | logger.log({ level: 'error', message: message || err.message, err }); 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/server.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'http'; 2 | 3 | /** 4 | * Return the TCP port number that the given server is listening on. 5 | * 6 | * @returns the port number, or -1 if the server isn't listening. 7 | */ 8 | export function getServerPort(server: http.Server): number { 9 | const address = server.address(); 10 | 11 | if (typeof address === 'string') { 12 | throw new Error( 13 | "This function doesn't handle a server listening on a pipe or unix domain socket." 14 | ); 15 | } else if (address === null) { 16 | return -1; 17 | } else { 18 | return address.port; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Converts a comma delimited list into an array. 3 | */ 4 | export function parseCommaDelimitedList(value: string): string[] { 5 | return value 6 | .split(',') 7 | .map((val) => val.trim()) 8 | .filter((val) => !!val); 9 | } 10 | 11 | export function intersectionNotEmpty(a: string[], b: string[]) { 12 | return a.some((aValue) => b.includes(aValue)); 13 | } 14 | 15 | export function generateHttpMessage( 16 | statusCode: number, 17 | reason: string, 18 | headers: { [header: string]: string } = {}, 19 | body = '' 20 | ) { 21 | return ( 22 | `HTTP/1.1 ${statusCode} ${reason}\r\n` + 23 | `connection: close\r\n` + 24 | Object.keys(headers) 25 | .map((key) => `${key}: ${headers[key]}\r\n`) 26 | .join('') + 27 | `content-length: ${body.length}\r\n` + 28 | '\r\n' + 29 | body 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /test/configTest.ts: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiAsPromised from 'chai-as-promised'; 3 | import { 4 | DEFAULT_COOKIE_NAME, 5 | DEFAULT_METRICS_PORT, 6 | DEFAULT_PORT, 7 | validateConfig, 8 | } from '../src/config'; 9 | import { SanitizedKubeAuthProxyConfig } from '../src/types'; 10 | 11 | chai.use(chaiAsPromised); 12 | const { expect } = chai; 13 | 14 | describe('config parser', function () { 15 | it('should fill in default values', async function () { 16 | const config = validateConfig({ 17 | domain: 'mydomain.com', 18 | auth: { 19 | github: { 20 | clientID: 'fake-client-id', 21 | clientSecret: 'fake-client-secret', 22 | }, 23 | }, 24 | }); 25 | 26 | const expectedConfig: SanitizedKubeAuthProxyConfig = { 27 | domain: 'mydomain.com', 28 | authDomain: 'auth.mydomain.com', 29 | port: DEFAULT_PORT, 30 | metricsPort: DEFAULT_METRICS_PORT, 31 | sessionCookieName: DEFAULT_COOKIE_NAME, 32 | sessionSecret: config.sessionSecret, 33 | secureCookies: true, 34 | defaultConditions: [], 35 | defaultTargets: [], 36 | auth: { 37 | github: { 38 | clientID: 'fake-client-id', 39 | clientSecret: 'fake-client-secret', 40 | }, 41 | }, 42 | }; 43 | 44 | expect(config).to.eql(expectedConfig); 45 | expect(config.sessionSecret).to.not.be.empty; 46 | expect(config.sessionSecret).to.be.string; 47 | }); 48 | 49 | it('should error for an invalid config', async function () { 50 | expect(() => validateConfig({} as any)).to.throw('domain required in configuration.'); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /test/fixtures/MockAuthModule.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { AuthModule } from '../../src/authModules/AuthModule'; 3 | import { SanitizedKubeAuthProxyConfig } from '../../src/types'; 4 | 5 | export default class MockAuthModule implements AuthModule { 6 | name = 'mock-auth'; 7 | 8 | authenticationMiddleware(_config: SanitizedKubeAuthProxyConfig): express.RequestHandler { 9 | const router = express.Router(); 10 | 11 | router.get('/kube-auth-proxy/mockauth', (req, res, next) => { 12 | const username = req.query.username; 13 | if (username && typeof username === 'string') { 14 | req.login({ type: 'mock-auth', username, emails: [] }, (err: any) => { 15 | if (err) { 16 | next(err); 17 | } else { 18 | res.redirect('/'); 19 | } 20 | }); 21 | } else { 22 | next(); 23 | } 24 | }); 25 | 26 | return router; 27 | } 28 | 29 | getLoginButton() { 30 | return 'Login with Mock Provider!'; 31 | } 32 | 33 | isEnabled() { 34 | return true; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /test/fixtures/makeSessionCookie.ts: -------------------------------------------------------------------------------- 1 | import clientSessions from 'client-sessions'; 2 | import { DEFAULT_COOKIE_NAME } from '../../src/config'; 3 | 4 | export function makeSessionCookieForUser(secret: string, user: any) { 5 | const sessionData = { 6 | passport: { 7 | user, 8 | }, 9 | }; 10 | 11 | const cookieData = clientSessions.util.encode( 12 | { cookieName: DEFAULT_COOKIE_NAME, secret, duration: 1000 * 60 * 5 }, 13 | sessionData 14 | ); 15 | 16 | return `${DEFAULT_COOKIE_NAME}=${cookieData}`; 17 | } 18 | -------------------------------------------------------------------------------- /test/fixtures/mockK8sApi.ts: -------------------------------------------------------------------------------- 1 | import * as k8s from '@kubernetes/client-node'; 2 | 3 | export function createMockK8sApi(objects: { secrets?: k8s.V1Secret[] }) { 4 | const processedSecrets = (objects.secrets || []).map((s) => { 5 | const secret = { 6 | ...s, 7 | }; 8 | if (secret.stringData) { 9 | secret.data = secret.data ? { ...secret.data } : {}; 10 | for (const key of Object.keys(secret.stringData)) { 11 | secret.data[key] = Buffer.from(secret.stringData[key], 'utf-8').toString('base64'); 12 | } 13 | } 14 | return secret; 15 | }); 16 | 17 | return { 18 | async readNamespacedSecret(secretName: string, namespace: string) { 19 | const result = processedSecrets.find( 20 | (secret) => 21 | secret.metadata?.name === secretName && secret.metadata?.namespace === namespace 22 | ); 23 | if (!result) { 24 | throw { 25 | response: { 26 | statusCode: 404, 27 | }, 28 | }; 29 | } 30 | 31 | return { body: result }; 32 | }, 33 | } as k8s.CoreV1Api; 34 | } 35 | -------------------------------------------------------------------------------- /test/fixtures/mockProxyTargetManager.ts: -------------------------------------------------------------------------------- 1 | import { ProxyTargetFinder } from '../../src/server/findTarget'; 2 | import { CompiledProxyTarget } from '../../src/targets'; 3 | 4 | export function mockProxyTargetManager(targets: CompiledProxyTarget[]): ProxyTargetFinder { 5 | return { 6 | findTarget(host: string) { 7 | return targets.find((target) => target.host === host); 8 | }, 9 | 10 | findTargetsForUser() { 11 | return targets; 12 | }, 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /test/fixtures/testServer.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import http from 'http'; 3 | import WebSocket from 'ws'; 4 | import { getServerPort } from '../../src/utils/server'; 5 | 6 | /** 7 | * Convenience function to start a new HTTP server. 8 | * Returns a `{server, port}` object. 9 | */ 10 | export async function makeTestServer( 11 | requestListener?: http.RequestListener 12 | ): Promise<{ server: http.Server; wss: WebSocket.Server; port: number }> { 13 | return new Promise((resolve) => { 14 | let app = requestListener; 15 | 16 | if (!app) { 17 | const expressApp = express(); 18 | expressApp.get('/hello', (_req, res) => res.send('Hello World!')); 19 | app = expressApp; 20 | } 21 | 22 | const server = http.createServer(app); 23 | 24 | server.listen(); 25 | 26 | const wss = new WebSocket.Server({ server }); 27 | 28 | server.on('listening', () => { 29 | const port = getServerPort(server); 30 | resolve({ server, wss, port }); 31 | }); 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /test/server/serverTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import express from 'express'; 3 | import http from 'http'; 4 | import pEvent from 'p-event'; 5 | import { makeFetch } from 'supertest-fetch'; 6 | import { DEFAULT_COOKIE_NAME } from '../../src/config'; 7 | import { startServer } from '../../src/server'; 8 | import { CompiledProxyTarget } from '../../src/targets'; 9 | import { KubeAuthProxyUser, SanitizedKubeAuthProxyConfig } from '../../src/types'; 10 | import { makeSessionCookieForUser } from '../fixtures/makeSessionCookie'; 11 | import MockAuthModule from '../fixtures/MockAuthModule'; 12 | import { mockProxyTargetManager } from '../fixtures/mockProxyTargetManager'; 13 | import { makeTestServer } from '../fixtures/testServer'; 14 | 15 | const SESSION_SECRET = 'woo'; 16 | 17 | const DEFAULT_CONFIG: SanitizedKubeAuthProxyConfig = { 18 | domain: 'test.com', 19 | authDomain: 'auth.test.com', 20 | secureCookies: false, 21 | sessionSecret: SESSION_SECRET, 22 | sessionCookieName: DEFAULT_COOKIE_NAME, 23 | defaultConditions: [], 24 | defaultTargets: [], 25 | }; 26 | 27 | const USER_JWALTON: KubeAuthProxyUser = { 28 | type: 'mock-auth', 29 | username: 'jwalton', 30 | emails: ['jwalton@service.com'], 31 | }; 32 | 33 | describe('Server Tests', function () { 34 | let testServer: http.Server; 35 | let testPort: number; 36 | let server: http.Server | undefined; 37 | let proxyTarget: CompiledProxyTarget; 38 | 39 | beforeAll(async function () { 40 | const app = express(); 41 | app.get('/hello', (_req, res) => res.send('Hello World!')); 42 | app.get('/authorization', (req, res) => { 43 | res.json(req.headers); 44 | }); 45 | 46 | ({ server: testServer, port: testPort } = await makeTestServer(app)); 47 | 48 | proxyTarget = { 49 | compiled: true, 50 | host: 'mock.test.com', 51 | key: 'mock', 52 | source: 'mock', 53 | targetUrl: `http://localhost:${testPort}`, 54 | wsTargetUrl: `ws://localhost:${testPort}`, 55 | conditions: [{ allowedEmails: [USER_JWALTON.emails[0]] }], 56 | validateCertificate: true, 57 | }; 58 | }); 59 | 60 | afterAll(function () { 61 | testServer.close(); 62 | }); 63 | 64 | afterEach(function () { 65 | if (server) { 66 | server.close(); 67 | } 68 | }); 69 | 70 | it('should require authentication', async function () { 71 | server = startServer(DEFAULT_CONFIG, mockProxyTargetManager([proxyTarget]), [ 72 | new MockAuthModule(), 73 | ]); 74 | await pEvent(server, 'listening'); 75 | 76 | const fetch = makeFetch(server); 77 | await fetch('/hello', { redirect: 'manual' }).expect(401, /Login with Mock Provider/); 78 | }); 79 | 80 | it('should proxy a request for an authorized user', async function () { 81 | server = startServer(DEFAULT_CONFIG, mockProxyTargetManager([proxyTarget]), [ 82 | new MockAuthModule(), 83 | ]); 84 | await pEvent(server, 'listening'); 85 | 86 | const fetch = makeFetch(server); 87 | await fetch('/hello', { 88 | headers: { 89 | host: 'mock.test.com', 90 | cookie: makeSessionCookieForUser(SESSION_SECRET, USER_JWALTON), 91 | }, 92 | }).expect(200, 'Hello World!'); 93 | }); 94 | 95 | it('should login a user', async function () { 96 | server = startServer(DEFAULT_CONFIG, mockProxyTargetManager([proxyTarget]), [ 97 | new MockAuthModule(), 98 | ]); 99 | await pEvent(server, 'listening'); 100 | 101 | const fetch = makeFetch(server); 102 | await fetch(`/kube-auth-proxy/mockauth?username=${USER_JWALTON.username}`, { 103 | headers: { 104 | host: 'mock.test.com', 105 | }, 106 | redirect: 'manual', 107 | }).expect(302); 108 | }); 109 | 110 | it('should proxy a request for an authenticated user, for a target with no conditions', async function () { 111 | const target = { 112 | ...proxyTarget, 113 | conditions: [], 114 | }; 115 | 116 | server = startServer(DEFAULT_CONFIG, mockProxyTargetManager([target]), [ 117 | new MockAuthModule(), 118 | ]); 119 | await pEvent(server, 'listening'); 120 | 121 | const fetch = makeFetch(server); 122 | await fetch('/hello', { 123 | headers: { 124 | host: 'mock.test.com', 125 | cookie: makeSessionCookieForUser(SESSION_SECRET, USER_JWALTON), 126 | }, 127 | }).expect(200, 'Hello World!'); 128 | }); 129 | 130 | it('should deny a request for an unauthorized user', async function () { 131 | const myProxyTarget = { 132 | ...proxyTarget, 133 | conditions: [{ allowedEmails: ['someone-else@foo.com'] }], 134 | }; 135 | server = startServer(DEFAULT_CONFIG, mockProxyTargetManager([myProxyTarget]), [ 136 | new MockAuthModule(), 137 | ]); 138 | await pEvent(server, 'listening'); 139 | 140 | const fetch = makeFetch(server); 141 | await fetch('/hello', { 142 | headers: { 143 | host: 'mock.test.com', 144 | cookie: makeSessionCookieForUser(SESSION_SECRET, USER_JWALTON), 145 | }, 146 | }).expect(403); 147 | }); 148 | 149 | it('should return a 404 if the host header does not resolve to a target', async function () { 150 | server = startServer(DEFAULT_CONFIG, mockProxyTargetManager([proxyTarget]), [ 151 | new MockAuthModule(), 152 | ]); 153 | await pEvent(server, 'listening'); 154 | 155 | const fetch = makeFetch(server); 156 | await fetch('/hello', { 157 | headers: { 158 | host: 'unknown.test.com', 159 | cookie: makeSessionCookieForUser(SESSION_SECRET, USER_JWALTON), 160 | }, 161 | }).expect(404); 162 | }); 163 | 164 | it('should add a authorization header', async function () { 165 | const target: CompiledProxyTarget = { 166 | ...proxyTarget, 167 | conditions: [], 168 | headers: { 169 | authorization: 'Bearer mr.token', 170 | }, 171 | }; 172 | 173 | server = startServer(DEFAULT_CONFIG, mockProxyTargetManager([target]), [ 174 | new MockAuthModule(), 175 | ]); 176 | await pEvent(server, 'listening'); 177 | 178 | const fetch = makeFetch(server); 179 | const result = await fetch('/authorization', { 180 | headers: { 181 | host: 'mock.test.com', 182 | cookie: makeSessionCookieForUser(SESSION_SECRET, USER_JWALTON), 183 | test: 'foo', 184 | }, 185 | }).expect(200); 186 | 187 | const headers = await result.json(); 188 | 189 | // Should add an authorization header. 190 | expect(headers.authorization).to.equal('Bearer mr.token'); 191 | 192 | // Make sure we don't clobber existing headers. 193 | expect(headers.test).to.equal('foo'); 194 | }); 195 | }); 196 | -------------------------------------------------------------------------------- /test/server/serverWsTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import express from 'express'; 3 | import http from 'http'; 4 | import { AddressInfo } from 'net'; 5 | import pEvent from 'p-event'; 6 | import WebSocket from 'ws'; 7 | import { DEFAULT_COOKIE_NAME } from '../../src/config'; 8 | import { startServer } from '../../src/server'; 9 | import { CompiledProxyTarget } from '../../src/targets'; 10 | import { KubeAuthProxyUser, SanitizedKubeAuthProxyConfig } from '../../src/types'; 11 | import { makeSessionCookieForUser } from '../fixtures/makeSessionCookie'; 12 | import MockAuthModule from '../fixtures/MockAuthModule'; 13 | import { mockProxyTargetManager } from '../fixtures/mockProxyTargetManager'; 14 | import { makeTestServer } from '../fixtures/testServer'; 15 | 16 | const SESSION_SECRET = 'woo'; 17 | 18 | const DEFAULT_CONFIG: SanitizedKubeAuthProxyConfig = { 19 | domain: 'test.com', 20 | authDomain: 'auth.test.com', 21 | secureCookies: false, 22 | sessionSecret: SESSION_SECRET, 23 | sessionCookieName: DEFAULT_COOKIE_NAME, 24 | defaultConditions: [], 25 | defaultTargets: [], 26 | }; 27 | 28 | const USER_JWALTON: KubeAuthProxyUser = { 29 | type: 'mock-auth', 30 | username: 'jwalton', 31 | emails: ['jwalton@service.com'], 32 | }; 33 | 34 | describe('Websocket Server Tests', function () { 35 | let testServer: http.Server; 36 | let testPort: number; 37 | let server: http.Server | undefined; 38 | let wss: WebSocket.Server; 39 | let proxyTarget: CompiledProxyTarget; 40 | let client: WebSocket | undefined; 41 | 42 | beforeAll(async function () { 43 | const app = express(); 44 | app.get('/hello', (_req, res) => res.send('Hello World!')); 45 | 46 | ({ server: testServer, port: testPort, wss } = await makeTestServer(app)); 47 | 48 | proxyTarget = { 49 | compiled: true, 50 | key: 'mock', 51 | source: 'mock', 52 | targetUrl: `http://localhost:${testPort}`, 53 | wsTargetUrl: `ws://localhost:${testPort}`, 54 | /** Will forward traffic to this endpoint if the "host" header starts with this string or is this string. */ 55 | host: 'mock.test.com', 56 | conditions: [{ allowedEmails: [USER_JWALTON.emails[0]] }], 57 | validateCertificate: true, 58 | }; 59 | 60 | wss.on('connection', (connection, req) => { 61 | connection.send('{"message": "Hello"}'); 62 | connection.on('message', (data) => { 63 | if (data.toString() === 'headers') { 64 | connection.send(JSON.stringify(req.headers)); 65 | } 66 | }); 67 | }); 68 | }); 69 | 70 | afterAll(function () { 71 | testServer.close(); 72 | }); 73 | 74 | afterEach(function () { 75 | if (server) { 76 | server.close(); 77 | } 78 | if (client) { 79 | client.close(); 80 | } 81 | }); 82 | 83 | it('should require authentication', async function () { 84 | server = startServer(DEFAULT_CONFIG, mockProxyTargetManager([proxyTarget]), [ 85 | new MockAuthModule(), 86 | ]); 87 | await pEvent(server, 'listening'); 88 | 89 | const address = server.address() as AddressInfo; 90 | client = new WebSocket(`ws://localhost:${address.port}/`, { 91 | headers: { 92 | host: 'mock.test.com', 93 | }, 94 | }); 95 | 96 | const err = await pEvent(client as any, 'error'); 97 | expect(err).to.exist; 98 | expect((err as Error).message).to.include('Unexpected server response: 401'); 99 | 100 | client.close(); 101 | }); 102 | 103 | it('should proxy a ws request for an authorized user', async function () { 104 | server = startServer(DEFAULT_CONFIG, mockProxyTargetManager([proxyTarget]), [ 105 | new MockAuthModule(), 106 | ]); 107 | await pEvent(server, 'listening'); 108 | 109 | const address = server.address() as AddressInfo; 110 | client = new WebSocket(`ws://localhost:${address.port}/`, { 111 | headers: { 112 | host: 'mock.test.com', 113 | cookie: makeSessionCookieForUser(SESSION_SECRET, USER_JWALTON), 114 | }, 115 | }); 116 | 117 | const message: string = await pEvent(client as any, 'message'); 118 | expect(JSON.parse(message)).to.eql({ message: 'Hello' }); 119 | 120 | client.close(); 121 | }); 122 | 123 | it('should proxy a ws request for an authenticated user, for a target with no conditions', async function () { 124 | const target = { 125 | ...proxyTarget, 126 | conditions: [], 127 | }; 128 | 129 | server = startServer(DEFAULT_CONFIG, mockProxyTargetManager([target]), [ 130 | new MockAuthModule(), 131 | ]); 132 | await pEvent(server, 'listening'); 133 | 134 | const address = server.address() as AddressInfo; 135 | client = new WebSocket(`ws://localhost:${address.port}/`, { 136 | headers: { 137 | host: 'mock.test.com', 138 | cookie: makeSessionCookieForUser(SESSION_SECRET, USER_JWALTON), 139 | }, 140 | }); 141 | 142 | const message: string = await pEvent(client as any, 'message'); 143 | expect(JSON.parse(message)).to.eql({ message: 'Hello' }); 144 | 145 | client.close(); 146 | }); 147 | 148 | it('should deny a ws request for an unauthorized user', async function () { 149 | const myProxyTarget = { 150 | ...proxyTarget, 151 | conditions: [{ allowedEmails: ['someone-else@foo.com'] }], 152 | }; 153 | server = startServer(DEFAULT_CONFIG, mockProxyTargetManager([myProxyTarget]), [ 154 | new MockAuthModule(), 155 | ]); 156 | await pEvent(server, 'listening'); 157 | 158 | const address = server.address() as AddressInfo; 159 | client = new WebSocket(`ws://localhost:${address.port}/`, { 160 | headers: { 161 | host: 'mock.test.com', 162 | cookie: makeSessionCookieForUser(SESSION_SECRET, USER_JWALTON), 163 | }, 164 | }); 165 | 166 | const err = await pEvent(client as any, 'error'); 167 | expect(err).to.exist; 168 | expect((err as Error).message).to.include('Unexpected server response: 403'); 169 | 170 | client.close(); 171 | }); 172 | 173 | it('should return a 404 if the host header does not resolve to a target', async function () { 174 | server = startServer(DEFAULT_CONFIG, mockProxyTargetManager([proxyTarget]), [ 175 | new MockAuthModule(), 176 | ]); 177 | await pEvent(server, 'listening'); 178 | 179 | const address = server.address() as AddressInfo; 180 | client = new WebSocket(`ws://localhost:${address.port}/`, { 181 | headers: { 182 | host: 'unknown.test.com', 183 | cookie: makeSessionCookieForUser(SESSION_SECRET, USER_JWALTON), 184 | }, 185 | }); 186 | 187 | const err = await pEvent(client as any, 'error'); 188 | expect(err).to.exist; 189 | expect((err as Error).message).to.include('Unexpected server response: 404'); 190 | 191 | client.close(); 192 | }); 193 | 194 | it('should add a authorization header', async function () { 195 | const target: CompiledProxyTarget = { 196 | ...proxyTarget, 197 | conditions: [], 198 | headers: { 199 | authorization: 'Bearer mr.token', 200 | }, 201 | }; 202 | 203 | server = startServer(DEFAULT_CONFIG, mockProxyTargetManager([target]), [ 204 | new MockAuthModule(), 205 | ]); 206 | await pEvent(server, 'listening'); 207 | 208 | const address = server.address() as AddressInfo; 209 | client = new WebSocket(`ws://localhost:${address.port}/`, { 210 | headers: { 211 | host: 'mock.test.com', 212 | cookie: makeSessionCookieForUser(SESSION_SECRET, USER_JWALTON), 213 | test: 'foo', 214 | }, 215 | }); 216 | 217 | const message: string = await pEvent(client as any, 'message'); 218 | expect(JSON.parse(message)).to.eql({ message: 'Hello' }); 219 | 220 | client.send('headers'); 221 | const headersMessage: string = await pEvent(client as any, 'message'); 222 | const headers = JSON.parse(headersMessage); 223 | 224 | // Should add an authorization header. 225 | expect(headers.authorization).to.equal('Bearer mr.token'); 226 | 227 | // Make sure we don't clobber existing headers. 228 | expect(headers.test).to.equal('foo'); 229 | }); 230 | }); 231 | -------------------------------------------------------------------------------- /test/targets/targetsTest.ts: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import { compileProxyTarget } from '../../src/targets/index'; 3 | import { createMockK8sApi } from '../fixtures/mockK8sApi'; 4 | 5 | const { expect } = chai; 6 | 7 | describe('targets', function () { 8 | describe('compileProxyTarget', function () { 9 | it('should compile a target', async function () { 10 | const target = await compileProxyTarget( 11 | undefined, 12 | { 13 | key: 'test', 14 | source: 'test', 15 | host: 'prometheus', 16 | to: { targetUrl: 'http://theservice.default:80' }, 17 | }, 18 | [] 19 | ); 20 | 21 | expect(target).to.eql({ 22 | compiled: true, 23 | key: 'test', 24 | source: 'test', 25 | targetUrl: 'http://theservice.default:80', 26 | wsTargetUrl: 'ws://theservice.default/', 27 | host: 'prometheus', 28 | conditions: [], 29 | validateCertificate: true, 30 | headers: undefined, 31 | }); 32 | }); 33 | 34 | it('should add default conditions if the target defines no conditions', async function () { 35 | const target = await compileProxyTarget( 36 | undefined, 37 | { 38 | key: 'test', 39 | source: 'test', 40 | host: 'prometheus', 41 | to: { targetUrl: 'http://theservice.default:80' }, 42 | }, 43 | [ 44 | { 45 | githubAllowedUsers: ['jwalton'], 46 | }, 47 | ] 48 | ); 49 | 50 | expect(target.conditions).to.eql([ 51 | { 52 | githubAllowedUsers: ['jwalton'], 53 | }, 54 | ]); 55 | }); 56 | 57 | it('should ignore default conditions if the target defines conditions', async function () { 58 | const target = await compileProxyTarget( 59 | undefined, 60 | { 61 | key: 'test', 62 | source: 'test', 63 | host: 'prometheus', 64 | to: { targetUrl: 'http://theservice.default:80' }, 65 | conditions: { 66 | githubAllowedOrganizations: ['exegesis-js'], 67 | }, 68 | }, 69 | [ 70 | { 71 | githubAllowedUsers: ['jwalton'], 72 | }, 73 | ] 74 | ); 75 | 76 | expect(target.conditions).to.eql([ 77 | { 78 | githubAllowedOrganizations: ['exegesis-js'], 79 | }, 80 | ]); 81 | }); 82 | 83 | it('should add a bearer token header', async function () { 84 | const k8sApi = createMockK8sApi({ 85 | secrets: [ 86 | { 87 | kind: 'secret', 88 | metadata: { 89 | name: 'thesecret', 90 | namespace: 'default', 91 | }, 92 | stringData: { 93 | data: 'secret', 94 | }, 95 | }, 96 | ], 97 | }); 98 | 99 | const target = await compileProxyTarget( 100 | k8sApi, 101 | { 102 | key: 'test', 103 | source: 'test', 104 | host: 'prometheus', 105 | to: { targetUrl: 'http://theservice.default:80' }, 106 | bearerTokenSecret: { 107 | namespace: 'default', 108 | secretName: 'thesecret', 109 | dataName: 'data', 110 | }, 111 | }, 112 | [] 113 | ); 114 | 115 | expect(target).to.eql({ 116 | compiled: true, 117 | key: 'test', 118 | source: 'test', 119 | targetUrl: 'http://theservice.default:80', 120 | wsTargetUrl: 'ws://theservice.default/', 121 | host: 'prometheus', 122 | conditions: [], 123 | validateCertificate: true, 124 | headers: { 125 | authorization: 'Bearer secret', 126 | }, 127 | }); 128 | }); 129 | 130 | it('should add a basic auth header from a secret', async function () { 131 | const k8sApi = createMockK8sApi({ 132 | secrets: [ 133 | { 134 | kind: 'secret', 135 | metadata: { 136 | name: 'thesecret', 137 | namespace: 'default', 138 | }, 139 | stringData: { 140 | data: 'secret', 141 | }, 142 | }, 143 | ], 144 | }); 145 | 146 | const target = await compileProxyTarget( 147 | k8sApi, 148 | { 149 | key: 'test', 150 | source: 'test', 151 | host: 'prometheus', 152 | to: { targetUrl: 'http://theservice.default:80' }, 153 | basicAuthUsername: 'jwalton', 154 | basicAuthPasswordSecret: { 155 | namespace: 'default', 156 | secretName: 'thesecret', 157 | dataName: 'data', 158 | }, 159 | }, 160 | [] 161 | ); 162 | 163 | expect(target.headers?.authorization).to.equal( 164 | `Basic ${Buffer.from('jwalton:secret').toString('base64')}` 165 | ); 166 | }); 167 | 168 | it('should add a basic auth header from a litteral', async function () { 169 | const target = await compileProxyTarget( 170 | undefined, 171 | { 172 | key: 'test', 173 | source: 'test', 174 | host: 'prometheus', 175 | to: { targetUrl: 'http://theservice.default:80' }, 176 | basicAuthUsername: 'jwalton', 177 | basicAuthPassword: 'secret', 178 | }, 179 | [] 180 | ); 181 | 182 | expect(target.headers?.authorization).to.equal( 183 | `Basic ${Buffer.from('jwalton:secret').toString('base64')}` 184 | ); 185 | }); 186 | }); 187 | }); 188 | -------------------------------------------------------------------------------- /test/targets/validationTest.ts: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import { validateProxyTarget } from '../../src/targets/validation'; 3 | 4 | const { expect } = chai; 5 | 6 | describe('targets - validation', function () { 7 | it('should validate a target', function () { 8 | expect(() => 9 | validateProxyTarget({ 10 | key: 'test', 11 | source: 'test', 12 | host: 'prometheus', 13 | to: { 14 | service: 'test', 15 | targetPort: 'web', 16 | }, 17 | }) 18 | ).to.not.throw(); 19 | }); 20 | 21 | it('should throw for an invalid target', function () { 22 | expect(() => 23 | validateProxyTarget({ 24 | key: 'test', 25 | source: 'test', 26 | to: { 27 | service: 'test', 28 | targetPort: 'web', 29 | }, 30 | } as any) 31 | ).to.throw(`Invalid ProxyTarget: must have required property 'host'`); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "typeRoots": ["../node_modules/@types", "../@types"] 6 | }, 7 | "include": ["./**/*"] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es2018", 5 | "module": "commonjs", 6 | "lib": ["ES2019", "DOM"], 7 | "allowJs": false, 8 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 9 | "declaration": true, 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "stripInternal": true, 13 | 14 | // Workaround for bugs in @types/express. 15 | "skipLibCheck": true, 16 | 17 | /* Strict Type-Checking Options */ 18 | "strict": true /* Enable all strict type-checking options. */, 19 | // "strictNullChecks": true, /* Enable strict null checks. */ 20 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 21 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 22 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 23 | "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */, 24 | "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */, 25 | 26 | /* Additional Checks */ 27 | "forceConsistentCasingInFileNames": true, 28 | "noUnusedLocals": true /* Report errors on unused locals. */, 29 | "noUnusedParameters": true /* Report errors on unused parameters. */, 30 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 31 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 32 | 33 | /* Module Resolution Options */ 34 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 35 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 36 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 37 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 38 | "typeRoots": [ 39 | /* List of folders to include type definitions from. */ 40 | "./node_modules/@types", 41 | "./@types" 42 | ], 43 | // "typeRoots": [], /* List of folders to include type definitions from. */ 44 | // "types": [], /* Type declaration files to be included in compilation. */ 45 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 46 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 47 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 48 | 49 | /* Source Map Options */ 50 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 51 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 52 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 53 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 54 | 55 | /* Experimental Options */ 56 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 57 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 58 | }, 59 | "include": ["./src/**/*"], 60 | "compileOnSave": true 61 | } 62 | --------------------------------------------------------------------------------