├── .babelrc ├── .dockerignore ├── .editorconfig ├── .eslintrc.js ├── .flowconfig ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── __mocks__ └── ldapts.js ├── jest.config.js ├── package.json ├── src ├── api │ ├── healthz.js │ ├── index.js │ ├── tokenAuthentication.js │ └── userAuthentication.js ├── app.js ├── config.js ├── index.js ├── ldap │ ├── __mocks__ │ │ ├── authenticator.js │ │ └── client.js │ ├── authenticator.js │ ├── client.js │ ├── index.js │ └── mapping.js └── logger.js ├── test ├── integration │ └── app.test.js └── unit │ ├── api │ ├── healthz.test.js │ ├── tokenAuthentication.test.js │ └── userAuthentication.test.js │ ├── config.test.js │ ├── ldap │ ├── authenticator.test.js │ ├── canonicalizeDn.test.js │ ├── client.test.js │ └── mapping.test.js │ └── mock │ └── index.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["flow", "env"], 3 | "plugins": [ 4 | "transform-flow-strip-types", 5 | "syntax-async-functions", 6 | "transform-regenerator", 7 | "transform-object-rest-spread" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .editorconfig 2 | build 3 | node_modules 4 | .git 5 | Dockerfile 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": [ 3 | "plugin:flowtype/recommended", 4 | "google", 5 | ], 6 | "rules": { 7 | "max-len": 1, 8 | }, 9 | "parser": "babel-eslint", 10 | "plugins": [ 11 | "flowtype" 12 | ], 13 | "parserOptions": { 14 | "ecmaVersion": 6, 15 | "sourceType" : "module", 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | 5 | [libs] 6 | flow-typed 7 | 8 | [options] 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | coverage 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | services: 3 | - docker 4 | language: node_js 5 | node_js: 6 | - "8" 7 | script: 8 | - yarn install 9 | - yarn run build 10 | - yarn run test 11 | - yarn run coveralls 12 | cache: 13 | directories: 14 | - "node_modules" 15 | yarn: true 16 | after_success: 17 | - (test "$TRAVIS_TAG" != "" || test "$TRAVIS_BRANCH" = "dev") && docker build -t gyselroth/kube-ldap:$TRAVIS_BRANCH . 18 | - (test "$TRAVIS_TAG" != "" || (test "$TRAVIS_BRANCH" = "dev" && test "$TRAVIS_PULL_REQUEST" = "false")) && docker login -u "$DOCKER_USERNAME" -p "$DOCKER_PASSWORD" 19 | - test "$TRAVIS_TAG" != "" && docker tag gyselroth/kube-ldap:$TRAVIS_BRANCH gyselroth/kube-ldap:latest 20 | - (test "$TRAVIS_TAG" != "" || (test "$TRAVIS_BRANCH" = "dev" && test "$TRAVIS_PULL_REQUEST" = "false")) && docker push gyselroth/kube-ldap:$TRAVIS_BRANCH 21 | - test "$TRAVIS_TAG" != "" && docker push gyselroth/kube-ldap:latest 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [2.0.1] - 2019-07-16 10 | ### Fixed 11 | - For every authentication or token review request a new ldap connection is used, instead using a single connection for all requests. This resolves problems where the single connection went unresponsive (https://github.com/gyselroth/kube-ldap/issues/27). 12 | 13 | ## [2.0.0] - 2019-06-12 14 | ### Added 15 | - Prometheus exporter on route "/metrics" (basic auth protected) 16 | 17 | ### Changed 18 | - **BREAKING:** Extra-Attributes and groups are now no longer included in the JWT issued after user authentication. Extra-Attributes and group memberships are now resolved during the token review and are included in the token review response 19 | - Internal: Use [ldapts](https://github.com/ldapts/ldapts) instead of [ldapjs](https://github.com/joyent/node-ldapjs) as ldap library 20 | 21 | ### Fixed 22 | - Fix membership resolution for ldap objects without any membership 23 | 24 | ### Removed 25 | - **BREAKING:** LDAP StartTLS is no longer supported 26 | - **BREAKING:** LDAP reconnect logic (now there's a new connection for every request) 27 | 28 | ## [1.3.0] - 2019-01-07 29 | ### Changed 30 | - Failed authentication sends a WWW-Authenticate header in the HTTP response 31 | - Default loglevel is now info (was debug) 32 | - Update node to latest 8.x LTS in docker image 33 | 34 | ### Added 35 | - LDAP related logging 36 | - Configuration parameter whether to use StartTLS for LDAP or not (enabled by default). 37 | 38 | ### Fixed 39 | - Single group memberships are returned as a string (instead of an array) by LDAP in some cases and broke the membership resolution. This is now handled correctly. 40 | - Fixed units in README for LDAP reconnect config parameters. 41 | 42 | ## [1.2.1] - 2018-07-19 43 | ### Added 44 | - LDAP reconnect logic (with configurable parameters) 45 | 46 | ## [1.2.0] - 2018-04-20 47 | ### Added 48 | - Configuration parameters for LDAP connection and operation timeouts. 49 | - Configurable mapping between LDAP and kubernetes attributes. 50 | 51 | ## [1.1.0] - 2018-03-27 52 | ### Security 53 | - TLS (HTTPS) support (enabled by default). 54 | 55 | ### Changed 56 | - Log error if a DN is not in a canonicalizable format. 57 | 58 | ## [1.0.0] - 2018-03-27 59 | ### Added 60 | - Initial key functionality 61 | 62 | [Unreleased]: https://github.com/gyselroth/kube-ldap/compare/v2.0.1...master 63 | [2.0.1]: https://github.com/gyselroth/kube-ldap/compare/v2.0.0...v2.0.1 64 | [2.0.0]: https://github.com/gyselroth/kube-ldap/compare/v1.3.0...v2.0.0 65 | [1.3.0]: https://github.com/gyselroth/kube-ldap/compare/v1.2.1...v1.3.0 66 | [1.3.0]: https://github.com/gyselroth/kube-ldap/compare/v1.2.1...v1.3.0 67 | [1.2.1]: https://github.com/gyselroth/kube-ldap/compare/v1.2.0...v1.2.1 68 | [1.2.0]: https://github.com/gyselroth/kube-ldap/compare/v1.1.0...v1.2.0 69 | [1.1.0]: https://github.com/gyselroth/kube-ldap/compare/v1.0.0...v1.1.0 70 | [1.0.0]: https://github.com/gyselroth/kube-ldap/tree/v1.0.0 71 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8.16.0-alpine 2 | 3 | RUN apk --no-cache add ca-certificates wget && \ 4 | wget -q -O /etc/apk/keys/sgerrand.rsa.pub https://alpine-pkgs.sgerrand.com/sgerrand.rsa.pub && \ 5 | wget https://github.com/sgerrand/alpine-pkg-glibc/releases/download/2.27-r0/glibc-2.27-r0.apk && \ 6 | apk add glibc-2.27-r0.apk && \ 7 | apk del wget 8 | 9 | COPY . /srv/www/kube-ldap 10 | RUN cd /srv/www/kube-ldap && \ 11 | yarn install && \ 12 | yarn run test && \ 13 | yarn run build && \ 14 | yarn install --production=true 15 | 16 | CMD ["node", "/srv/www/kube-ldap/build/index.js"] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 gyselroth 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kube-ldap 2 | [![Build Status](https://travis-ci.org/gyselroth/kube-ldap.svg)](https://travis-ci.org/gyselroth/kube-ldap) 3 | [![Coverage Status](https://coveralls.io/repos/github/gyselroth/kube-ldap/badge.svg)](https://coveralls.io/github/gyselroth/kube-ldap) 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 5 | 6 | A [Webhook Token Authentication](https://kubernetes.io/docs/admin/authentication/#webhook-token-authentication) plugin for kubernetes, written in javascript, to use LDAP as authentication source. 7 | 8 | ## Description 9 | The kube-ldap webhook token authentication plugin can be used to integrate username/password authentication via LDAP for your kubernetes cluster. 10 | It exposes two API endpoints: 11 | * /auth 12 | * HTTP basic authenticated requests to this endpoint result in a JSON Web Token, signed by the webhook, including the username and uid of the authenticated user. 13 | * The issued token can be used for authenticating to kubernetes. 14 | * /token 15 | * Is called by kubernetes (see [TokenReview](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.9/#tokenreview-v1-authentication)) to verify the token used for authentication. 16 | * Verifies the integrity of the JWT (using the signature) and returns a TokenReview response containing the username, uid, group memberships and extra attributes (if configured) of the authenticated user. 17 | 18 | ## Deployment 19 | The recommended way to deploy kube-ldap is deplyoing kube-ldap in kubernetes itself using the [gyselroth/kube-ldap](https://hub.docker.com/r/gyselroth/kube-ldap/) docker image. 20 | 21 | Example YAML for kubernetes (secrets, deployment including tls termination and service): 22 | ```yaml 23 | apiVersion: v1 24 | data: 25 | key: #base64 encoded jwt key (see "Configuration" in README) 26 | kind: Secret 27 | metadata: 28 | name: kube-ldap-jwt-key 29 | namespace: kube-system 30 | type: Opaque 31 | --- 32 | apiVersion: v1 33 | data: 34 | binddn: #base64 encoded bind dn (see "Configuration" in README) 35 | bindpw: #base64 encoded bind password (see "Configuration" in README) 36 | kind: Secret 37 | metadata: 38 | name: kube-ldap-ldap-bind-credentials 39 | namespace: kube-system 40 | type: Opaque 41 | --- 42 | apiVersion: v1 43 | data: 44 | cert.pem: #base64 encoded certificate (pem) 45 | key.pem: #base64 encoded private key (pem) 46 | kind: Secret 47 | metadata: 48 | name: kube-ldap-tls 49 | namespace: kube-system 50 | type: Opaque 51 | --- 52 | apiVersion: apps/v1beta2 53 | kind: Deployment 54 | metadata: 55 | labels: 56 | k8s-app: kube-ldap 57 | name: kube-ldap 58 | namespace: kube-system 59 | spec: 60 | replicas: 1 61 | selector: 62 | matchLabels: 63 | k8s-app: kube-ldap 64 | template: 65 | metadata: 66 | labels: 67 | k8s-app: kube-ldap 68 | spec: 69 | volumes: 70 | - name: kube-ldap-tls 71 | secret: 72 | secretName: kube-ldap-tls 73 | containers: 74 | - env: 75 | - name: LDAP_URI 76 | value: #ldap uri (see "Configuration" in README) 77 | - name: LDAP_BINDDN 78 | valueFrom: 79 | secretKeyRef: 80 | name: kube-ldap-ldap-bind-credentials 81 | key: binddn 82 | - name: LDAP_BINDPW 83 | valueFrom: 84 | secretKeyRef: 85 | name: kube-ldap-ldap-bind-credentials 86 | key: bindpw 87 | - name: LDAP_BASEDN 88 | value: #ldap base dn (see "Configuration" in README) 89 | - name: LDAP_FILTER 90 | value: #ldap filter(see "Configuration" in README) 91 | - name: LOGLEVEL 92 | value: info 93 | - name: JWT_KEY 94 | valueFrom: 95 | secretKeyRef: 96 | name: kube-ldap-jwt-key 97 | key: key 98 | - name: JWT_TOKEN_LIFETIME 99 | value: #jwt token lifetime (see "Configuration" in README) 100 | image: gyselroth/kube-ldap:latest # Better use fixed version tag here since 'latest' can point to new major releases with breaking changes 101 | volumeMounts: 102 | - name: kube-ldap-tls 103 | mountPath: "/etc/ssl/kube-ldap" 104 | livenessProbe: 105 | httpGet: 106 | path: /healthz 107 | port: 8080 108 | scheme: HTTP 109 | initialDelaySeconds: 5 110 | periodSeconds: 10 111 | name: kube-ldap 112 | ports: 113 | - containerPort: 8081 114 | --- 115 | apiVersion: v1 116 | kind: Service 117 | metadata: 118 | labels: 119 | k8s-app: kube-ldap 120 | name: kube-ldap 121 | namespace: kube-system 122 | spec: 123 | ports: 124 | - port: 8081 125 | protocol: TCP 126 | targetPort: 8081 127 | selector: 128 | k8s-app: kube-ldap 129 | type: ClusterIP 130 | ``` 131 | 132 | 133 | ## Configuration 134 | ### kube-ldap 135 | kube-ldap itself can be configured via environment variables. 136 | 137 | List of configurable values: 138 | 139 | |Setting|Description|Environment Variable| Default Value| 140 | |-------|-----------|--------------------|--------------| 141 | |`config.port`|HTTP port to listen|`PORT`|8081 (8080 if TLS is disabled)| 142 | |`config.loglevel`|Loglevel for winston logger. **CAUTION: debug loglevel may log sensitive parameters like user passwords**|`LOGLEVEL`|info| 143 | |`config.tls.enabled`|Enable TLS (HTTPS). **DO NOT DISABLE IN PRODUCTION UNLESS YOU HAVE A TLS REVERSE PROXY IN PLACE**|`TLS_ENABLED` ("true" or "false")|true| 144 | |`config.tls.cert`|Path to certificate (pem) to use for TLS (HTTPS)|`TLS_CERT_PATH`|/etc/ssl/kube-ldap/cert.pem| 145 | |`config.tls.key`|Path to private key (pem) to use for TLS (HTTPS)|`TLS_KEY_PATH`|/etc/ssl/kube-ldap/key.pem| 146 | |`config.tls.ca`|*Optional: Path to ca certificate (pem) to use for TLS (HTTPS)*|`TLS_CA_PATH`|*none*| 147 | |`config.ldap.uri`|URI of LDAP server|`LDAP_URI`|ldap://ldap.example.com| 148 | |`config.ldap.binddn`|DN of LDAP bind user connection|`LDAP_BINDDN`|uid=bind,dc=example,dc=com| 149 | |`config.ldap.bindpw`|Password of LDAP bind user|`LDAP_BINDPW`|secret| 150 | |`config.ldap.baseDn`|Base DN for LDAP search|`LDAP_BASEDN`|dc=example,dc=com| 151 | |`config.ldap.filter`|Filter for LDAP search|`LDAP_FILTER`|(uid=%s)| 152 | |`config.ldap.timeout`|Timeout for LDAP connections & operations (in seconds)|`LDAP_TIMEOUT`|0 (infinite for operations, OS default for connections)| 153 | |`config.mapping.username`|Name of ldap attribute to be used as username in kubernetes TokenReview|`MAPPING_USERNAME`|uid| 154 | |`config.mapping.uid`|Name of ldap attribute to be used as uid in kubernetes TokenReview|`MAPPING_UID`|uid| 155 | |`config.mapping.groups`|Name of ldap attribute to be used for groups in kubernetes TokenReview|`MAPPING_GROUPS`|memberOf| 156 | |`config.mapping.extraFields`|Comma separated list of additional ldap attributes to be used for extra in kubernetes TokenReview|`MAPPING_EXTRAFIELDS`|[]| 157 | |`config.mapping.username`|Name of Ldap attribute to be used as username in kubernetes TokenReview|`MAPPING_USERNAME`|uid| 158 | |`config.jwt.key`|Key for signing the JWT. **DO NOT USE THE DEFAULT VALUE IN PRODUCTION**|`JWT_KEY`|secret| 159 | |`config.jwt.tokenLifetime`|Seconds until token a expires|`JWT_TOKEN_LIFETIME`|28800| 160 | |`config.prometheus.username`|Username for prometheus exporter basic auth (use empty string to disable basic auth)|`PROMETHEUS_USERNAME`|prometheus| 161 | |`config.prometheus.password`|Password for prometheus exporter basic auth (use empty string to disable basic auth)|`PROMETHEUS_PASSWORD`|secret| 162 | |`config.prometheus.nodejsProbeInterval`|Probe interval for nodejs metrics in milliseconds|`PROMETHEUS_NODEJS_PROBE_INTERVAL`|10000| 163 | 164 | ### kubernetes 165 | Configure your kubernetes apiserver to use the kube-ldap [webhook for authentication](https://kubernetes.io/docs/admin/authentication/#webhook-token-authentication) using the following configuration file. 166 | ```yaml 167 | # clusters refers to the remote service. 168 | clusters: 169 | - name: kube-ldap 170 | cluster: 171 | server: https://your-kube-ldap-url/token 172 | 173 | # users refers to the API server's webhook configuration. 174 | users: 175 | - name: apiserver 176 | 177 | # kubeconfig files require a context. Provide one for the API server. 178 | current-context: webhook 179 | contexts: 180 | - context: 181 | cluster: kube-ldap 182 | user: apiserver 183 | name: webhook 184 | ``` 185 | 186 | ### kubectl 187 | You can either use [kube-ldap-client-go-exec-plugin](https://github.com/gyselroth/kube-ldap-client-go-exec-plugin), a kubectl plugin ([client-go credential plugin](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#client-go-credential-plugins)) doing the authentication for you, or you can do it manually. 188 | 189 | To configure `kubectl` for manual authentication initially: 190 | ```bash 191 | TOKEN=$(curl https://your-kube-ldap-url/auth -u your-username) 192 | kubectl config set-cluster your-cluster --server=https://your-apiserver-url [...] 193 | kubectl config set-credentials your-cluster-ldap --token="$TOKEN" 194 | kubectl config set-context your-cluster --cluster=your-cluster --user=your-cluster-ldap 195 | ``` 196 | 197 | To refresh your token after expiration: 198 | ```bash 199 | TOKEN=$(curl https://your-kube-ldap-url/auth -u your-username) 200 | kubectl config set-credentials your-cluster-ldap --token="$TOKEN" 201 | ``` 202 | 203 | 204 | ## Development 205 | ### Requirements 206 | * nodejs 207 | * yarn 208 | 209 | ### Development Server 210 | During development an auto-reloading development server (using babel watch) can be used. 211 | 212 | Remember to set the environment variables required to configure kube-ldap. E.g.: 213 | ```bash 214 | LDAP_URI=ldap://ldap.example.local TLS_ENABLED=false yarn start 215 | ``` 216 | 217 | ### Test 218 | To run automated tests using jest you can use yarn: 219 | ```bash 220 | yarn test 221 | ``` 222 | 223 | ### Build 224 | kube-ldap can be built via yarn, to get native nodejs code, or via docker (which uses yarn), to get a docker image. 225 | 226 | Either way the build process lints the code (including flow type annotations) before building. When building via docker the process also includes running the automated tests. 227 | If any of these steps fail, the build will fail too. 228 | 229 | #### yarn 230 | When using yarn, it places the build output in `./build/` directory. 231 | ```bash 232 | yarn build 233 | ``` 234 | 235 | #### docker 236 | When using docker, the `./Dockerfile` is used to build an image. 237 | ```bash 238 | docker -t kube-ldap build . 239 | ``` 240 | -------------------------------------------------------------------------------- /__mocks__/ldapts.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | declare var jest: any; 3 | 4 | let staticMock = null; 5 | /** Mock of Authenticator class */ 6 | class LdapMock { 7 | bindReturnsError: boolean; 8 | searchReturnsError: boolean; 9 | searchReturnsResult: boolean; 10 | searchResult: Object; 11 | bind: (string, Array) => Promise; 12 | unbind: () => void; 13 | search: (string, Array) => Promise; 14 | on: (string, (any) => any) => void; 15 | 16 | /** creates the mock */ 17 | constructor() { 18 | if (staticMock) { 19 | return staticMock; 20 | } 21 | staticMock = this; 22 | this.bindReturnsError = false; 23 | this.searchReturnsError = false; 24 | this.searchReturnsResult = true; 25 | this.searchResult = {}; 26 | this.bind = jest.fn(); 27 | this.bind.mockImplementation((dn, password, controls) => { 28 | if (this.bindReturnsError) { 29 | throw new Error('error by mock'); 30 | } else { 31 | return null; 32 | } 33 | }); 34 | this.unbind = jest.fn(); 35 | this.search = jest.fn(); 36 | this.search.mockImplementation((base, options, controls) => { 37 | if (this.searchReturnsError) { 38 | throw new Error('error by mock'); 39 | } else { 40 | return { 41 | searchEntries: this.searchReturnsResult ? [this.searchResult] : [], 42 | }; 43 | } 44 | }); 45 | } 46 | } 47 | 48 | export {LdapMock as Client}; 49 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | verbose: true, 3 | testEnvironment: 'node', 4 | collectCoverageFrom: [ 5 | 'src/**', 6 | '!src/index.js', 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kube-ldap", 3 | "version": "2.0.1", 4 | "description": "kubernetes token webhook to check bearer tokens against ldap", 5 | "main": "src/index.js", 6 | "author": "Fabian Jucker ", 7 | "license": "MIT", 8 | "dependencies": { 9 | "atob": "^2.0.3", 10 | "babel-polyfill": "^6.26.0", 11 | "body-parser": "^1.18.2", 12 | "bunyan-winston-adapter": "^0.2.0", 13 | "cors": "^2.8.4", 14 | "express": "^4.16.3", 15 | "express-basic-auth": "^1.2.0", 16 | "express-prom-bundle": "^5.1.5", 17 | "jsonwebtoken": "^8.2.0", 18 | "ldapts": "^1.7.0", 19 | "morgan": "^1.9.0", 20 | "prom-client": "^11.5.0", 21 | "winston": "^2.4.1" 22 | }, 23 | "devDependencies": { 24 | "babel-cli": "^6.26.0", 25 | "babel-eslint": "^8.2.2", 26 | "babel-jest": "^22.4.3", 27 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 28 | "babel-plugin-transform-regenerator": "^6.26.0", 29 | "babel-preset-env": "^1.6.1", 30 | "babel-preset-flow": "^6.23.0", 31 | "babel-watch": "^2.0.7", 32 | "coveralls": "^3.0.1", 33 | "eslint": "^4.19.0", 34 | "eslint-config-google": "^0.9.1", 35 | "eslint-plugin-flowtype": "^2.46.1", 36 | "flow-bin": "^0.68.0", 37 | "jest": "^22.4.3", 38 | "supertest": "^3.0.0" 39 | }, 40 | "scripts": { 41 | "start": "babel-watch src/index.js", 42 | "build": "yarn run eslint --fix src/ && yarn run flow && yarn run babel src/ --ignore=__mocks__/** -d build/", 43 | "prepublish": "yarn run build", 44 | "test": "jest --coverage", 45 | "coveralls": "coveralls < coverage/lcov.info" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/api/healthz.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | /** Class for Healthz API route */ 4 | export default class Healthz { 5 | /** 6 | * Run API route 7 | * @param {Object} req - Request. 8 | * @param {Object} res - Response. 9 | */ 10 | run(req: Object, res: Object) { 11 | res.send('OK'); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/api/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import Healthz from './healthz'; 3 | import TokenAuthentication from './tokenAuthentication'; 4 | import UserAuthentication from './userAuthentication'; 5 | 6 | export { 7 | Healthz, 8 | TokenAuthentication, 9 | UserAuthentication, 10 | }; 11 | -------------------------------------------------------------------------------- /src/api/tokenAuthentication.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import {Logger} from 'winston'; 3 | import jwt from 'jsonwebtoken'; 4 | import {Authenticator, Mapping} from '../ldap'; 5 | 6 | /** Class for TokenAuthentication API route */ 7 | export default class TokenAuthentication { 8 | authenticator: Authenticator; 9 | mapping: Mapping; 10 | key: string; 11 | logger: Logger; 12 | run: (Object, Object) => void 13 | extractAndVerifyToken: (string) => Object 14 | 15 | /** 16 | * Create API route 17 | * @param {Authenticator} authenticator - Authenticator. 18 | * @param {Mapping} mapping - Attribute mapping (kubernetes<=>ldap). 19 | * @param {string} key - Private key. 20 | * @param {Logger} logger - Logger to use. 21 | */ 22 | constructor( 23 | authenticator: Authenticator, 24 | mapping: Mapping, 25 | key: string, 26 | logger: Logger 27 | ) { 28 | this.authenticator = authenticator; 29 | this.mapping = mapping; 30 | this.key = key; 31 | this.logger = logger; 32 | this.run = this.run.bind(this); 33 | this.extractAndVerifyToken = this.extractAndVerifyToken.bind(this); 34 | } 35 | 36 | /** 37 | * Run API route 38 | * @param {Object} req - Request. 39 | * @param {Object} res - Response. 40 | */ 41 | async run(req: Object, res: Object): Promise { 42 | if ( 43 | !req.body.apiVersion || 44 | !req.body.kind || 45 | !req.body.spec || 46 | !req.body.spec.token 47 | ) { 48 | res.sendStatus(400); 49 | } else if ( 50 | req.body.apiVersion !== 'authentication.k8s.io/v1beta1' || 51 | req.body.kind !== 'TokenReview' 52 | ) { 53 | res.sendStatus(400); 54 | } else { 55 | let token =req.body.spec.token; 56 | try { 57 | let responseData = await this._processToken(token); 58 | res.send(responseData); 59 | } catch (error) { 60 | this.logger.error(error); 61 | res.sendStatus(500); 62 | } 63 | } 64 | } 65 | 66 | /** 67 | * Process token 68 | * @param {string} token - The token. 69 | * @return {Object} 70 | */ 71 | async _processToken(token: string): Promise { 72 | let responseData = { 73 | apiVersion: 'authentication.k8s.io/v1beta1', 74 | kind: 'TokenReview', 75 | status: { 76 | authenticated: false, 77 | user: {}, 78 | }, 79 | }; 80 | try { 81 | let tokenData = this.extractAndVerifyToken(token); 82 | let ldapObject = await this.authenticator.getAttributes( 83 | tokenData.username, 84 | this.mapping.getLdapAttributes() 85 | ); 86 | responseData.status.user = this.mapping.ldapToKubernetes(ldapObject); 87 | responseData.status.authenticated = true; 88 | } catch (error) { 89 | delete responseData.status.user; 90 | responseData.status.authenticated = false; 91 | this.logger.info('Error while verifying token: ' + 92 | `[${error.name}] with message [${error.message}]`); 93 | } 94 | return responseData; 95 | } 96 | 97 | /** 98 | * Validate token 99 | * @param {string} token - The token. 100 | * @return {Object} 101 | */ 102 | extractAndVerifyToken(token: string): Object { 103 | try { 104 | return jwt.verify(token, this.key); 105 | } catch (error) { 106 | throw error; 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/api/userAuthentication.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import {Logger} from 'winston'; 3 | import atob from 'atob'; 4 | import jwt from 'jsonwebtoken'; 5 | import {Authenticator} from '../ldap'; 6 | 7 | /** Class for UserAuthentication API route */ 8 | export default class UserAuthentication { 9 | authenticator: Authenticator; 10 | tokenLifetime: number; 11 | key: string; 12 | logger: Logger; 13 | run: (Object, Object) => void 14 | 15 | /** 16 | * Create API route 17 | * @param {Authenticator} authenticator - Authenticator. 18 | * @param {number} tokenLifetime - Token lifetime. 19 | * @param {string} key - Private key for token signing. 20 | * @param {Logger} logger - Logger to use. 21 | */ 22 | constructor( 23 | authenticator: Authenticator, 24 | tokenLifetime: number, 25 | key: string, 26 | logger: Logger 27 | ) { 28 | this.authenticator = authenticator; 29 | this.tokenLifetime = tokenLifetime; 30 | this.key = key; 31 | this.logger = logger; 32 | this.run = this.run.bind(this); 33 | } 34 | 35 | /** 36 | * Run API route 37 | * @param {Object} req - Request. 38 | * @param {Object} res - Response. 39 | * @return {Promise} 40 | */ 41 | run(req: Object, res: Object) { 42 | let authHeader = req.get('Authorization'); 43 | if (!authHeader) { 44 | this._sendUnauthorized(res); 45 | } else { 46 | try { 47 | let credentials = UserAuthentication.parseBasicAuthHeader(authHeader); 48 | return this.authenticator.authenticate( 49 | credentials.username, 50 | credentials.password 51 | ).then((success) => { 52 | if (!success) { 53 | this._sendUnauthorized(res); 54 | } else { 55 | try { 56 | res.send(this.getToken(credentials.username)); 57 | } catch (error) { 58 | this.logger.error(error.message); 59 | res.sendStatus(500); 60 | } 61 | } 62 | }, (error) => { 63 | this.logger.error(error.message); 64 | res.sendStatus(500); 65 | }); 66 | } catch (error) { 67 | this.logger.error(error.message); 68 | res.sendStatus(400); 69 | } 70 | } 71 | } 72 | 73 | /** 74 | * Send an HTTP 401 (Unauthorized) response including a WWW-Authenticate header 75 | * @param {Object} res - Response. 76 | */ 77 | _sendUnauthorized(res: Object): void { 78 | res.set('WWW-Authenticate', 'Basic realm="kubernetes"'); 79 | res.sendStatus(401); 80 | } 81 | 82 | /** 83 | * Get/Generate token for user 84 | * @param {string} username - Username. 85 | * @return {string} 86 | */ 87 | getToken(username: string): Promise { 88 | try { 89 | let token = jwt.sign({username: username}, this.key, { 90 | expiresIn: this.tokenLifetime, 91 | }); 92 | 93 | return token; 94 | } catch (error) { 95 | throw error; 96 | } 97 | } 98 | 99 | /** 100 | * Parse HTTP "Authorization" header into username/password 101 | * @param {string} authHeader - Authorization header. 102 | * @return {Object} 103 | */ 104 | static parseBasicAuthHeader(authHeader: string): Object { 105 | let parts = authHeader.split(' '); 106 | if (parts[0] !== 'Basic' || parts.length < 2) { 107 | throw new Error('not a valid http basic authorization header'); 108 | } 109 | 110 | let credentials = atob(parts[1]).split(':'); 111 | if (credentials.length < 2) { 112 | throw new Error('not a valid http basic authorization header'); 113 | } 114 | return { 115 | username: credentials[0], 116 | password: credentials[1], 117 | }; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import 'babel-polyfill'; 3 | import express from 'express'; 4 | import bodyParser from 'body-parser'; 5 | import cors from 'cors'; 6 | import morgan from 'morgan'; 7 | import basicAuth from 'express-basic-auth'; 8 | import prometheusBundle from 'express-prom-bundle'; 9 | import {Client as Connection} from 'ldapts'; 10 | import {config, getConfig} from './config'; 11 | import logger from './logger'; 12 | import {Client, Authenticator, Mapping} from './ldap'; 13 | import {Healthz, UserAuthentication, TokenAuthentication} from './api'; 14 | 15 | // setup basic dependencies 16 | const connectionFactory = () => { 17 | return new Connection({ 18 | url: config.ldap.uri, 19 | timeout: config.ldap.timeout * 1000, 20 | connectTimeout: config.ldap.timeout * 1000, 21 | }); 22 | }; 23 | let ldapClient = new Client( 24 | connectionFactory, 25 | config.ldap.baseDn, 26 | config.ldap.bindDn, 27 | config.ldap.bindPw, 28 | ); 29 | let authenticator = new Authenticator(ldapClient, config.ldap.filter, logger); 30 | 31 | // setup api dependencies 32 | let healthz = new Healthz(); 33 | let userAuthentication = new UserAuthentication( 34 | authenticator, 35 | config.jwt.tokenLifetime, 36 | config.jwt.key, 37 | logger, 38 | ); 39 | let tokenAuthentication = new TokenAuthentication( 40 | authenticator, 41 | new Mapping( 42 | config.mapping.username, 43 | config.mapping.uid, 44 | config.mapping.groups, 45 | config.mapping.extraFields, 46 | ), 47 | config.jwt.key, 48 | logger 49 | ); 50 | 51 | // setup prometheus exporter 52 | let prometheusExporter = prometheusBundle({ 53 | includeMethod: true, 54 | includePath: true, 55 | promClient: { 56 | collectDefaultMetrics: { 57 | timeout: config.prometheus.nodejsProbeInterval, 58 | }, 59 | }, 60 | }); 61 | let prometheusBasicAuth = (req, res, next) => { 62 | let config = getConfig(); 63 | if ( 64 | Boolean(config.prometheus.username) && 65 | Boolean(config.prometheus.password) 66 | ) { 67 | basicAuth({ 68 | users: { 69 | [config.prometheus.username]: config.prometheus.password, 70 | }, 71 | })(req, res, next); 72 | } else { 73 | next(); 74 | } 75 | }; 76 | 77 | // setup express 78 | const app = express(); 79 | app.use(cors()); 80 | app.use(morgan('combined', { 81 | stream: { 82 | write: (message, encoding) => { 83 | logger.info(message); 84 | }, 85 | }, 86 | })); 87 | app.use('/metrics', prometheusBasicAuth); 88 | app.use(prometheusExporter); 89 | app.get('/healthz', healthz.run); 90 | app.get('/auth', userAuthentication.run); 91 | app.post('/token', bodyParser.json(), tokenAuthentication.run); 92 | app.use((err, req, res, next) => { 93 | logger.error(err); 94 | res.sendStatus(500); 95 | }); 96 | 97 | export default app; 98 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const parseBooleanFromEnv = (env: ?string, defaultValue: boolean): boolean => { 4 | return env == 'true' ? true : (env == 'false' ? false : defaultValue); 5 | }; 6 | 7 | const parseArrayFromEnv = ( 8 | env: ?string, 9 | delimiter: string, 10 | defaultValue: Array 11 | ): Array => { 12 | return env ? env.split(delimiter) : defaultValue; 13 | }; 14 | 15 | const parseNullableFromEnv = ( 16 | env: ?string, 17 | defaultValue: any, 18 | ): any => { 19 | return typeof(env) === 'undefined' ? defaultValue : (env || null); 20 | }; 21 | 22 | const getConfig = () => { 23 | let config = { 24 | loglevel: process.env.LOGLEVEL || 'info', 25 | ldap: { 26 | uri: process.env.LDAP_URI || 'ldap://ldap.example.com', 27 | bindDn: process.env.LDAP_BINDDN || 'uid=bind,dc=example,dc=com', 28 | bindPw: process.env.LDAP_BINDPW || 'secret', 29 | baseDn: process.env.LDAP_BASEDN || 'dc=example,dc=com', 30 | filter: process.env.LDAP_FILTER || '(uid=%s)', 31 | timeout: parseInt(process.env.LDAP_TIMEOUT) || 0, 32 | }, 33 | mapping: { 34 | username: process.env.MAPPING_USERNAME || 'uid', 35 | uid: process.env.MAPPING_UID|| 'uid', 36 | groups: process.env.MAPPING_GROUPS || 'memberOf', 37 | extraFields: parseArrayFromEnv( 38 | process.env.MAPPING_EXTRAFIELDS, 39 | ',', 40 | [] 41 | ), 42 | }, 43 | jwt: { 44 | key: process.env.JWT_KEY || 'secret', 45 | tokenLifetime: parseInt(process.env.JWT_TOKEN_LIFETIME) || 28800, 46 | }, 47 | tls: { 48 | enabled: parseBooleanFromEnv(process.env.TLS_ENABLED, true), 49 | cert: process.env.TLS_CERT_PATH || '/etc/ssl/kube-ldap/cert.pem', 50 | key: process.env.TLS_KEY_PATH || '/etc/ssl/kube-ldap/key.pem', 51 | ca: process.env.TLS_CA_PATH || null, 52 | }, 53 | prometheus: { 54 | username: parseNullableFromEnv( 55 | process.env.PROMETHEUS_USERNAME, 56 | 'prometheus' 57 | ), 58 | password: parseNullableFromEnv(process.env.PROMETHEUS_PASSWORD, 'secret'), 59 | nodejsProbeInterval: parseInt( 60 | process.env.PROMETHEUS_NODEJS_PROBE_INTERVAL 61 | ) || 10000, 62 | }, 63 | port: 0, 64 | }; 65 | config.port = parseInt(process.env.PORT) || ( 66 | config.tls.enabled ? 8081 : 8080 67 | ); 68 | return config; 69 | }; 70 | 71 | const config = getConfig(); 72 | 73 | export {config, getConfig}; 74 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import https from 'https'; 3 | import fs from 'fs'; 4 | import {config} from './config'; 5 | import logger from './logger'; 6 | import app from './app'; 7 | 8 | if (config.tls.enabled) { 9 | https.createServer({ 10 | cert: fs.readFileSync(config.tls.cert), 11 | key: fs.readFileSync(config.tls.key), 12 | ca: config.tls.ca ? fs.readFileSync(config.tls.ca) : null, 13 | }, app).listen(config.port, () => { 14 | logger.info(`kube-ldap listening on https port ${config.port}`); 15 | }); 16 | } else { 17 | app.listen(config.port, () => { 18 | logger.info(`kube-ldap listening on http port ${config.port}`); 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /src/ldap/__mocks__/authenticator.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | declare var jest: any; 3 | /** Mock of Authenticator class */ 4 | export default class AuthenticatorMock { 5 | authenticated: boolean; 6 | attributes: Object; 7 | getAttributesShouldThrowError: boolean; 8 | getAttributes: (string, Array) => Promise; 9 | authenticate: (string, string) => Promise; 10 | 11 | /** creates the mock */ 12 | constructor() { 13 | this.authenticated = false; 14 | this.attributes = {}; 15 | this.getAttributesShouldThrowError = false; 16 | this.getAttributes = jest.fn(); 17 | this.getAttributes.mockImplementation((username, attributes) => { 18 | return new Promise((resolve, reject) => { 19 | if (this.getAttributesShouldThrowError) { 20 | reject('rejected by mock'); 21 | } 22 | resolve(this.attributes); 23 | }); 24 | }); 25 | this.authenticate = jest.fn(); 26 | this.authenticate.mockImplementation((username, password) => { 27 | return new Promise((resolve) => { 28 | resolve(this.authenticated); 29 | }); 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/ldap/__mocks__/client.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | declare var jest: any; 3 | /** Mock of Client class */ 4 | export default class ClientMock { 5 | bindSuccess: boolean; 6 | searchResult: Object; 7 | searchShouldReject: boolean; 8 | bind: (string, string) => Promise; 9 | search: (string, ?Array, ?string) => Promise; 10 | 11 | /** creates the mock 12 | */ 13 | constructor() { 14 | this.bindSuccess = false; 15 | this.bind = jest.fn(); 16 | this.bind.mockImplementation((dn: string, password: string) => { 17 | return new Promise((resolve) => resolve(this.bindSuccess)); 18 | }); 19 | this.search = jest.fn(); 20 | this.search.mockImplementation( 21 | (filter: string, attributes: ?Array, basedn: ?string) => { 22 | return new Promise((resolve, reject) => { 23 | if (this.searchShouldReject) { 24 | reject('rejected by mock'); 25 | } 26 | resolve(this.searchResult); 27 | }); 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/ldap/authenticator.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import {Logger} from 'winston'; 3 | import util from 'util'; 4 | import Client from './client'; 5 | 6 | /** Class for an LDAP authenticator */ 7 | export default class Authenticator { 8 | client: Client; 9 | filter: string; 10 | logger: Logger; 11 | 12 | /** 13 | * Create an LDAP authenticator. 14 | * @param {Client} client - The ldap client. 15 | * @param {string} filter - filter for authenticated users. 16 | * @param {Logger} logger - Logger to use. 17 | */ 18 | constructor(client: Client, filter: string, logger: Logger) { 19 | this.client = client; 20 | this.filter = filter; 21 | this.logger = logger; 22 | } 23 | 24 | /** 25 | * Authenticate user on ldap. 26 | * @param {string} username - username to authenticate. 27 | * @param {string} password - password to authenticate. 28 | * @return {Promise} 29 | */ 30 | async authenticate(username: string, password: string): Promise { 31 | let filter = util.format(this.filter, username); 32 | try { 33 | let user = await this.client.search(filter); 34 | return await this.client.bind(user.dn, password); 35 | } catch (error) { 36 | this.logger.info(error.message); 37 | return false; 38 | } 39 | } 40 | 41 | /** 42 | * Get Attributes of user from ldap 43 | * @param {string} username - username to authenticate. 44 | * @param {Array} attributes - attributes to fetch. 45 | * @return {Promise} 46 | */ 47 | async getAttributes( 48 | username: string, 49 | attributes: Array 50 | ): Promise { 51 | let filter = util.format(this.filter, username); 52 | try { 53 | let result = await this.client.search(filter, attributes); 54 | return Object.keys(result) 55 | .filter((attribute) => attributes.includes(attribute)) 56 | .reduce((object, attribute) => { 57 | return { 58 | ...object, 59 | [attribute]: result[attribute], 60 | }; 61 | }, {}); 62 | } catch (error) { 63 | throw error; 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/ldap/client.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /** Class for an LDAP client */ 3 | export default class Client { 4 | clientFactory: () => Object; 5 | basedn: string; 6 | binddn: string; 7 | bindpw: string; 8 | 9 | /** 10 | * Create an LDAP client. 11 | * @param {Function} connectionFactory - Ldap connection factory. 12 | * @param {string} basedn - The base DN to use. 13 | * @param {string} binddn - DN of the bind user to use. 14 | * @param {string} bindpw - Password of the bind user to use. 15 | */ 16 | constructor( 17 | connectionFactory: () => Object, 18 | basedn: string, 19 | binddn: string, 20 | bindpw: string 21 | ) { 22 | this.clientFactory = connectionFactory; 23 | this.basedn = basedn; 24 | this.binddn = binddn; 25 | this.bindpw = bindpw; 26 | } 27 | 28 | /** 29 | * Perform LDAP bind operation 30 | * @param {string} dn - DN to bind. 31 | * @param {string} password - Password to bind. 32 | * @return {Promise} 33 | */ 34 | async bind(dn: string, password: string): Promise { 35 | let client = this.clientFactory(); 36 | let authenticated = false; 37 | try { 38 | await client.bind(dn, password, []); 39 | authenticated = true; 40 | } catch (error) { 41 | authenticated = false; 42 | } finally { 43 | await client.unbind(); 44 | } 45 | return authenticated; 46 | } 47 | 48 | /** 49 | * Perform LDAP search operation 50 | * @param {string} filter - LDAP search filter. 51 | * @param {Array} attributes - List of attributes (optional). 52 | * @param {string} basedn - The base DN to use (optional). 53 | * @return {Promise} Promise fulfilled with search result 54 | */ 55 | async search( 56 | filter: string, 57 | attributes: ?Array, 58 | basedn: ?string 59 | ): Promise { 60 | let client = this.clientFactory(); 61 | if (!basedn) { 62 | basedn = this.basedn; 63 | } 64 | 65 | let searchResult = null; 66 | try { 67 | await client.bind(this.binddn, this.bindpw, []); 68 | const options = { 69 | filter: filter, 70 | scope: 'sub', 71 | attributes: attributes, 72 | }; 73 | searchResult = await client.search(basedn, options, []); 74 | } catch (error) { 75 | throw error; 76 | } finally { 77 | await client.unbind(); 78 | } 79 | if (searchResult && searchResult.searchEntries.length > 0) { 80 | return searchResult.searchEntries[0]; 81 | } else { 82 | throw new Error(`no object found with filter [${filter}]`); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/ldap/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import Client from './client'; 3 | import Authenticator from './authenticator'; 4 | import Mapping from './mapping'; 5 | 6 | let canonicalizeDn = (dn: string) => { 7 | let firstPart = dn.split(',')[0].split('='); 8 | if (firstPart.length < 2) { 9 | throw new Error('invalid dn'); 10 | } 11 | return firstPart[1]; 12 | }; 13 | 14 | export { 15 | Client, 16 | Authenticator, 17 | Mapping, 18 | canonicalizeDn, 19 | }; 20 | -------------------------------------------------------------------------------- /src/ldap/mapping.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import {canonicalizeDn} from '../ldap'; 3 | 4 | /** Class for an Kubernetes<=>LDAP attribute mapping */ 5 | class Mapping { 6 | username: string; 7 | uid: string; 8 | groups: string; 9 | extraFields: Array 10 | 11 | /** 12 | * Create an Kubernetes<=>LDAP attribute mapping. 13 | * @param {string} username - Attribute name for the kubernetes username. 14 | * @param {string} uid - Attribute name for the kubernetes uid. 15 | * @param {string} groups - Attribute name for the kubernetes groups array. 16 | * @param {Array} extraFields - Array of kubernetes extra attributes 17 | */ 18 | constructor( 19 | username: string, 20 | uid: string, 21 | groups: string, 22 | extraFields: Array 23 | ) { 24 | this.username = username; 25 | this.uid = uid; 26 | this.groups = groups; 27 | this.extraFields = extraFields; 28 | } 29 | 30 | /** 31 | * Get array of LDAP attribute names 32 | * @return {Array} 33 | */ 34 | getLdapAttributes(): Array { 35 | let attributes = [this.username, this.uid, this.groups]; 36 | return attributes.concat(this.extraFields); 37 | } 38 | 39 | /** 40 | * Convert ldap object to kubernetes 41 | * @param {Object} ldapObject - Ldap object to convert 42 | * @param {boolean} withGroupAndExtra - Include groups and extra attributes 43 | * @return {Object} 44 | */ 45 | ldapToKubernetes(ldapObject: Object, withGroupAndExtra: boolean = true): Object { 46 | let groupAndExtra = {}; 47 | if (withGroupAndExtra) { 48 | groupAndExtra = { 49 | groups: this.getGroups(ldapObject).map((group) => { 50 | return canonicalizeDn(group); 51 | }), 52 | extra: this.extraFields.reduce((object, field) => { 53 | return { 54 | ...object, 55 | [field]: ldapObject[field], 56 | }; 57 | }, {}), 58 | }; 59 | } 60 | return { 61 | username: ldapObject[this.username], 62 | uid: ldapObject[this.uid], 63 | ...groupAndExtra, 64 | }; 65 | } 66 | 67 | /** 68 | * Get group of LDAP object 69 | * @param {Object} ldapObject - Ldap object to convert 70 | * @return {Array} 71 | */ 72 | getGroups(ldapObject: Object): Array { 73 | let groups = ldapObject[this.groups]; 74 | if (groups instanceof Array) { 75 | return groups; 76 | } else { 77 | return groups ? [groups] : []; 78 | } 79 | } 80 | } 81 | 82 | export default Mapping; 83 | -------------------------------------------------------------------------------- /src/logger.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import {Logger, transports} from 'winston'; 3 | import {config} from './config'; 4 | 5 | const logger = new Logger({ 6 | level: config.loglevel, 7 | transports: [ 8 | new transports.Console({ 9 | handleExceptions: true, 10 | timestamp: true, 11 | }), 12 | ], 13 | exitOnError: false, 14 | }); 15 | 16 | export default logger; 17 | -------------------------------------------------------------------------------- /test/integration/app.test.js: -------------------------------------------------------------------------------- 1 | import {Client as Connection} from 'ldapts'; 2 | import request from 'supertest'; 3 | import jwt from 'jsonwebtoken'; 4 | import {config} from '../../src/config'; 5 | import app from '../../src/app'; 6 | 7 | const fixtures = { 8 | ldap: { 9 | username: 'john.doe', 10 | memberOf: [ 11 | 'cn=test,dc=example,dc=com', 12 | ], 13 | }, 14 | tokenReviewPayload: { 15 | username: 'john.doe', 16 | iat: Math.floor(Date.now() / 1000), 17 | exp: Math.floor(Date.now() / 1000 + 3600), 18 | }, 19 | tokenReviewResponse: { 20 | username: 'john.doe', 21 | uid: 'john.doe', 22 | groups: [ 23 | 'test', 24 | ], 25 | extra: {}, 26 | }, 27 | tokenReviewTemplate: { 28 | apiVersion: 'authentication.k8s.io/v1beta1', 29 | kind: 'TokenReview', 30 | spec: { 31 | 'token': null, 32 | }, 33 | }, 34 | }; 35 | 36 | let connection = new Connection(); 37 | 38 | describe('test app routes', () => { 39 | beforeEach(() => { 40 | connection.bindReturnsError = false; 41 | connection.searchReturnsError = false; 42 | connection.searchResult = { 43 | uid: fixtures.ldap.username, 44 | memberOf: fixtures.ldap.memberOf, 45 | }; 46 | }); 47 | test('GET /healthz: 200 OK', (done) => { 48 | request(app) 49 | .get('/healthz') 50 | .expect(200, 'OK') 51 | .end((err) => { 52 | done(err); 53 | }); 54 | }); 55 | test('GET /auth: 200 OK', (done) => { 56 | request(app) 57 | .get('/auth') 58 | .auth('john.doe', 'secret') 59 | .expect(200) 60 | .expect((response) => { 61 | try { 62 | jwt.verify(response.text, config.jwt.key); 63 | } catch (error) { 64 | throw new Error( 65 | '[' + error.name + '] while validating JWT: ' + error.message 66 | ); 67 | } 68 | }) 69 | .expect((response) => { 70 | let object = jwt.decode(response.text); 71 | expect(object.username).toBe(fixtures.tokenReviewPayload.username); 72 | }) 73 | .end((error) => { 74 | done(error); 75 | }); 76 | }); 77 | test('GET /auth: 400 Bad Request', (done) => { 78 | request(app) 79 | .get('/auth') 80 | .set('Authorization', 'something') 81 | .expect(400) 82 | .end((error) => { 83 | done(error); 84 | }); 85 | }); 86 | test('GET /auth: 401 Unauthorized (No authorization header)', (done) => { 87 | request(app) 88 | .get('/auth') 89 | .expect(401) 90 | .end((error) => { 91 | done(error); 92 | }); 93 | }); 94 | test('GET /auth: 401 Unauthorized (Wrong username/password)', (done) => { 95 | connection.bindReturnsError = true; 96 | request(app) 97 | .get('/auth') 98 | .auth('john.doe', 'secret') 99 | .expect(401) 100 | .end((error) => { 101 | done(error); 102 | }); 103 | }); 104 | test('POST /token: 200 OK', (done) => { 105 | let tokenReview = fixtures.tokenReviewTemplate; 106 | tokenReview.spec.token = jwt.sign(fixtures.tokenReviewPayload, config.jwt.key); 107 | request(app) 108 | .post('/token') 109 | .send(tokenReview) 110 | .expect(200) 111 | .expect((response) => { 112 | let object = response.body; 113 | expect(object.apiVersion).toBe(fixtures.tokenReviewTemplate.apiVersion); 114 | expect(object.kind).toBe(fixtures.tokenReviewTemplate.kind); 115 | expect(object.status.authenticated).toBe(true); 116 | expect(object.status.user).toEqual(fixtures.tokenReviewResponse); 117 | }) 118 | .end((error) => { 119 | done(error); 120 | }); 121 | }); 122 | test('POST /token: 400 Bad Request', (done) => { 123 | request(app) 124 | .post('/token') 125 | .send({}) 126 | .expect(400) 127 | .end((error) => { 128 | done(error); 129 | }); 130 | }); 131 | test('GET /metrics: 200 OK (enabled basic auth)', (done) => { 132 | request(app) 133 | .post('/metrics') 134 | .auth(config.prometheus.username, config.prometheus.password) 135 | .expect(200) 136 | .end((error) => { 137 | done(error); 138 | }); 139 | }); 140 | test('GET /metrics: 200 OK (with disabled basic auth)', (done) => { 141 | process.env.PROMETHEUS_USERNAME = ''; 142 | process.env.PROMETHEUS_PASSWORD = ''; 143 | request(app) 144 | .post('/metrics') 145 | .expect(200) 146 | .end((error) => { 147 | delete process.env.PROMETHEUS_USERNAME; 148 | delete process.env.PROMETHEUS_PASSWORD; 149 | done(error); 150 | }); 151 | }); 152 | test('GET /metrics: 401 Unauthorized (No authorization header)', (done) => { 153 | request(app) 154 | .post('/metrics') 155 | .expect(401) 156 | .end((error) => { 157 | done(error); 158 | }); 159 | }); 160 | test('GET /metrics: 401 Unauthorized (Wrong username/password)', (done) => { 161 | request(app) 162 | .post('/metrics') 163 | .auth('john.doe', 'secret') 164 | .expect(401) 165 | .end((error) => { 166 | done(error); 167 | }); 168 | }); 169 | test('GET /metrics: 401 Unauthorized (Wrong username/password)', (done) => { 170 | request(app) 171 | .post('/metrics') 172 | .auth('john.doe', 'secret') 173 | .expect(401) 174 | .end((error) => { 175 | done(error); 176 | }); 177 | }); 178 | }); 179 | -------------------------------------------------------------------------------- /test/unit/api/healthz.test.js: -------------------------------------------------------------------------------- 1 | import Healthz from '../../../src/api/healthz'; 2 | 3 | describe('Healthz.run()', () => { 4 | test('Sends "OK"', () => { 5 | let healthz = new Healthz(); 6 | const responseMock = { 7 | send: jest.fn(), 8 | }; 9 | healthz.run({}, responseMock); 10 | expect(responseMock.send).toHaveBeenCalled(); 11 | expect(responseMock.send.mock.calls[0][0]).toBe('OK'); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /test/unit/api/tokenAuthentication.test.js: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | import {getResponseMock, getRequestMock} from '../mock'; 3 | import TokenAuthentication from '../../../src/api/tokenAuthentication'; 4 | import {Authenticator, Mapping} from '../../../src/ldap'; 5 | jest.mock('../../../src/ldap/authenticator'); 6 | 7 | const fixtures = { 8 | key: 'testsecret', 9 | validToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.ajB221IVz7aggEfTp3jUPc7UBw5xemmp-LmrmEgFETU', 10 | validTokenPayload: { 11 | sub: '1234567890', 12 | name: 'John Doe', 13 | iat: 1516239022, 14 | }, 15 | invalidSignatureToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkphbmUgRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.1VAmegkQOBsQ-iSZU96z6c8NY7QR9OhhYWYHwHTeGT4', 16 | expiredToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkphbmUgRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyOTkwMjJ9.Vuok0UaMvQdtU8wkPIqg_t34OSM6j0cRqFJet4X8xvQ', 17 | requestTemplate: { 18 | apiVersion: 'authentication.k8s.io/v1beta1', 19 | kind: 'TokenReview', 20 | spec: { 21 | 'token': null, 22 | }, 23 | }, 24 | notAuthenticatedResponse: { 25 | apiVersion: 'authentication.k8s.io/v1beta1', 26 | kind: 'TokenReview', 27 | status: { 28 | authenticated: false, 29 | }, 30 | }, 31 | authenticatedResponseTemplate: { 32 | apiVersion: 'authentication.k8s.io/v1beta1', 33 | kind: 'TokenReview', 34 | status: { 35 | authenticated: true, 36 | user: {}, 37 | }, 38 | }, 39 | mapping: new Mapping( 40 | 'uid', 41 | 'uid', 42 | 'memberOf', 43 | [ 44 | 'uidNumber', 45 | 'gidNumber', 46 | ] 47 | ), 48 | validTokenResponse: { 49 | username: 'john.doe', 50 | groups: [ 51 | 'test', 52 | ], 53 | extra: { 54 | uidNumber: 1, 55 | gidNumber: 10, 56 | }, 57 | uid: 'john.doe', 58 | }, 59 | }; 60 | 61 | let authenticator = new Authenticator(); 62 | const tokenAuthentication = new TokenAuthentication( 63 | authenticator, 64 | fixtures.mapping, 65 | fixtures.key, 66 | winston 67 | ); 68 | 69 | const generateRequestBody = (token) => { 70 | let requestBody = Object.assign({}, fixtures.requestTemplate); 71 | requestBody.spec.token = token; 72 | return requestBody; 73 | }; 74 | 75 | const generateAuthenticatedResponseBody = (payload) => { 76 | let responseBody = fixtures.authenticatedResponseTemplate; 77 | responseBody.status.user = payload; 78 | return responseBody; 79 | }; 80 | 81 | beforeEach(() => { 82 | authenticator.authenticated = true; 83 | authenticator.attributes = { 84 | uid: fixtures.validTokenResponse.username, 85 | memberOf: fixtures.validTokenResponse.groups.map((group) => { 86 | return 'cn=' + group + ',dc=example,dc=com'; 87 | }), 88 | uidNumber: fixtures.validTokenResponse.extra.uidNumber, 89 | gidNumber: fixtures.validTokenResponse.extra.gidNumber, 90 | }; 91 | authenticator.getAttributesShouldThrowError = false; 92 | }); 93 | 94 | describe('TokenAuthentication.extractAndVerifyToken()', () => { 95 | test('Returns token content', () => { 96 | expect(tokenAuthentication.extractAndVerifyToken(fixtures.validToken)) 97 | .toEqual(fixtures.validTokenPayload); 98 | }); 99 | 100 | test('Throws error on token with invalid signature', () => { 101 | expect(() => { 102 | tokenAuthentication.extractAndVerifyToken(fixtures.invalidSignatureToken); 103 | }).toThrow(); 104 | }); 105 | 106 | test('Throws error on expired token', () => { 107 | expect(() => { 108 | tokenAuthentication.extractAndVerifyToken(fixtures.expiredToken); 109 | }).toThrow(); 110 | }); 111 | }); 112 | 113 | describe('TokenAuthentication.run()', () => { 114 | test('Sends token payload on valid token', () => { 115 | const requestMock = getRequestMock( 116 | generateRequestBody(fixtures.validToken) 117 | ); 118 | const responseMock = getResponseMock(); 119 | 120 | return tokenAuthentication.run(requestMock, responseMock).then(() => { 121 | expect(responseMock.send).toHaveBeenCalled(); 122 | expect(responseMock.send.mock.calls[0][0]) 123 | .toEqual(generateAuthenticatedResponseBody(fixtures.validTokenResponse)); 124 | }); 125 | }); 126 | 127 | test('Sends "not-authenticated" response on invalid token', () => { 128 | const requestMock = getRequestMock( 129 | generateRequestBody(fixtures.invalidSignatureToken) 130 | ); 131 | const responseMock = getResponseMock(); 132 | 133 | return tokenAuthentication.run(requestMock, responseMock).then(() => { 134 | expect(responseMock.send).toHaveBeenCalled(); 135 | expect(responseMock.send.mock.calls[0][0]) 136 | .toEqual(fixtures.notAuthenticatedResponse); 137 | }); 138 | }); 139 | 140 | test('Sends "not-authenticated" response on invalid token', () => { 141 | const requestMock = getRequestMock( 142 | generateRequestBody(fixtures.expiredToken) 143 | ); 144 | const responseMock = getResponseMock(); 145 | 146 | return tokenAuthentication.run(requestMock, responseMock).then(() => { 147 | expect(responseMock.send).toHaveBeenCalled(); 148 | expect(responseMock.send.mock.calls[0][0]) 149 | .toEqual(fixtures.notAuthenticatedResponse); 150 | }); 151 | }); 152 | 153 | test('Sends 400 response on invalid request', () => { 154 | const requestMock = getRequestMock(''); 155 | const responseMock = getResponseMock(); 156 | 157 | tokenAuthentication.run(requestMock, responseMock); 158 | 159 | expect(responseMock.sendStatus).toHaveBeenCalled(); 160 | expect(responseMock.sendStatus.mock.calls[0][0]) 161 | .toEqual(400); 162 | }); 163 | 164 | test('Sends 400 response on non-TokenReview request', () => { 165 | let body = generateRequestBody(fixtures.invalidSignatureToken); 166 | body.kind = 'Something'; 167 | const requestMock = getRequestMock(body); 168 | const responseMock = getResponseMock(); 169 | 170 | tokenAuthentication.run(requestMock, responseMock); 171 | 172 | expect(responseMock.sendStatus).toHaveBeenCalled(); 173 | expect(responseMock.sendStatus.mock.calls[0][0]) 174 | .toEqual(400); 175 | }); 176 | 177 | test('Sends "not-authenticated" on internal server error (e.g. ldap error)', () => { 178 | authenticator.getAttributesShouldThrowError = true; 179 | const requestMock = getRequestMock( 180 | generateRequestBody(fixtures.validToken) 181 | ); 182 | const responseMock = getResponseMock(); 183 | 184 | return tokenAuthentication.run(requestMock, responseMock).then(() => { 185 | expect(responseMock.send).toHaveBeenCalled(); 186 | expect(responseMock.send.mock.calls[0][0]) 187 | .toEqual(fixtures.notAuthenticatedResponse); 188 | }); 189 | }); 190 | }); 191 | -------------------------------------------------------------------------------- /test/unit/api/userAuthentication.test.js: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | import {getResponseMock, getRequestMock} from '../mock'; 3 | import jwt from 'jsonwebtoken'; 4 | import UserAuthentication from '../../../src/api/userAuthentication'; 5 | import {Authenticator} from '../../../src/ldap'; 6 | jest.mock('../../../src/ldap/authenticator'); 7 | 8 | const fixtures = { 9 | key: 'testsecret', 10 | lifetime: 3600, 11 | username: 'john.doe', 12 | password: 'secret', 13 | authHeader: 'Basic am9obi5kb2U6c2VjcmV0', 14 | }; 15 | 16 | let authenticator = new Authenticator(); 17 | const userAuthentication = new UserAuthentication( 18 | authenticator, 19 | fixtures.lifetime, 20 | fixtures.key, 21 | winston); 22 | 23 | beforeEach(() => { 24 | authenticator.authenticated = true; 25 | }); 26 | 27 | describe('UserAuthentication.run()', () => { 28 | test('Sends token on authenticated user', () => { 29 | const requestMock = getRequestMock( 30 | '', 31 | {'Authorization': fixtures.authHeader} 32 | ); 33 | const responseMock = getResponseMock(); 34 | 35 | expect.hasAssertions(); 36 | return userAuthentication.run(requestMock, responseMock).then(() => { 37 | expect(responseMock.send).toHaveBeenCalled(); 38 | let token = jwt.decode(responseMock.send.mock.calls[0][0], {complete: true}); 39 | expect(token.header.typ).toBe('JWT'); 40 | expect(token.payload.username).toBe(fixtures.username); 41 | }); 42 | }); 43 | 44 | test('Sends 401 on uauthenticated user', () => { 45 | authenticator.authenticated = false; 46 | const requestMock = getRequestMock( 47 | '', 48 | {'Authorization': fixtures.authHeader} 49 | ); 50 | const responseMock = getResponseMock(); 51 | 52 | expect.hasAssertions(); 53 | return userAuthentication.run(requestMock, responseMock).then(() => { 54 | expect(responseMock.set).toHaveBeenCalled(); 55 | expect(responseMock.set.mock.calls[0][0]) 56 | .toEqual('WWW-Authenticate', 'Basic realm="kubernetes"'); 57 | expect(responseMock.sendStatus).toHaveBeenCalled(); 58 | expect(responseMock.sendStatus.mock.calls[0][0]) 59 | .toEqual(401); 60 | }); 61 | }); 62 | 63 | test('Sends 401 on missing auth header', () => { 64 | const requestMock = getRequestMock(); 65 | const responseMock = getResponseMock(); 66 | 67 | userAuthentication.run(requestMock, responseMock); 68 | expect(responseMock.set).toHaveBeenCalled(); 69 | expect(responseMock.set.mock.calls[0][0]) 70 | .toEqual('WWW-Authenticate', 'Basic realm="kubernetes"'); 71 | expect(responseMock.sendStatus).toHaveBeenCalled(); 72 | expect(responseMock.sendStatus.mock.calls[0][0]) 73 | .toEqual(401); 74 | }); 75 | 76 | test('Sends 400 on invalid auth header', () => { 77 | const requestMock = getRequestMock('', {'Authorization': 'Basic test'}); 78 | const responseMock = getResponseMock(); 79 | 80 | userAuthentication.run(requestMock, responseMock); 81 | 82 | expect(responseMock.sendStatus).toHaveBeenCalled(); 83 | expect(responseMock.sendStatus.mock.calls[0][0]) 84 | .toEqual(400); 85 | }); 86 | 87 | test('Sends 400 on not http-basic auth header', () => { 88 | const requestMock = getRequestMock('', {'Authorization': 'Bearer test'}); 89 | const responseMock = getResponseMock(); 90 | 91 | userAuthentication.run(requestMock, responseMock); 92 | 93 | expect(responseMock.sendStatus).toHaveBeenCalled(); 94 | expect(responseMock.sendStatus.mock.calls[0][0]) 95 | .toEqual(400); 96 | }); 97 | 98 | test('Sends 500 on invalid key', () => { 99 | const requestMock = getRequestMock( 100 | '', 101 | {'Authorization': fixtures.authHeader} 102 | ); 103 | const responseMock = getResponseMock(); 104 | 105 | const failingUserAuthentication = new UserAuthentication( 106 | authenticator, 107 | fixtures.lifetime, 108 | undefined, 109 | winston); 110 | 111 | failingUserAuthentication.run(requestMock, responseMock).then(() => { 112 | expect(responseMock.sendStatus).toHaveBeenCalled(); 113 | expect(responseMock.sendStatus.mock.calls[0][0]) 114 | .toEqual(500); 115 | }); 116 | }); 117 | }); 118 | 119 | describe('UserAuthentication.getToken()', () => { 120 | test('Returns token on valid request', () => { 121 | let now = Math.floor(new Date() / 1000); 122 | 123 | expect.hasAssertions(); 124 | let result = userAuthentication.getToken(fixtures.username); 125 | let token = jwt.decode(result, {complete: true}); 126 | expect(token.header.typ).toBe('JWT'); 127 | expect(token.payload.username).toBe(fixtures.username); 128 | expect(token.payload.exp / 60).toBeCloseTo((now + fixtures.lifetime) / 60); 129 | expect(jwt.verify(result, fixtures.key)); 130 | }); 131 | }); 132 | 133 | describe('UserAuthentication.parseBasicAuthHeader()', () => { 134 | test('Throws Error if header is not basic auth', () => { 135 | expect(() => { 136 | UserAuthentication.parseBasicAuthHeader('Bearer test'); 137 | }).toThrow(); 138 | }); 139 | test('Throws Error if basic auth header is invalid', () => { 140 | expect(() => { 141 | UserAuthentication.parseBasicAuthHeader('Basic test'); 142 | }).toThrow(); 143 | }); 144 | test('Returns user and password from header', () => { 145 | let result = UserAuthentication.parseBasicAuthHeader(fixtures.authHeader); 146 | expect(result.username).toBe(fixtures.username); 147 | expect(result.password).toBe(fixtures.password); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /test/unit/config.test.js: -------------------------------------------------------------------------------- 1 | import {getConfig} from '../../src/config'; 2 | 3 | const fixtures = { 4 | 'loglevel': { 5 | env: 'LOGLEVEL', 6 | default: 'info', 7 | testValues: ['debug'], 8 | }, 9 | 'ldap.uri': { 10 | env: 'LDAP_URI', 11 | default: 'ldap://ldap.example.com', 12 | testValues: ['ldap://ldap.example.local'], 13 | }, 14 | 'ldap.bindDn': { 15 | env: 'LDAP_BINDDN', 16 | default: 'uid=bind,dc=example,dc=com', 17 | testValues: ['uid=bind,dc=example,dc=local'], 18 | }, 19 | 'ldap.bindPw': { 20 | env: 'LDAP_BINDPW', 21 | default: 'secret', 22 | testValues: ['topsecret'], 23 | }, 24 | 'ldap.baseDn': { 25 | env: 'LDAP_BASEDN', 26 | default: 'dc=example,dc=com', 27 | testValues: ['dc=example,dc=local'], 28 | }, 29 | 'ldap.filter': { 30 | env: 'LDAP_FILTER', 31 | default: '(uid=%s)', 32 | testValues: ['(mail=%s)'], 33 | }, 34 | 'ldap.timeout': { 35 | env: 'LDAP_TIMEOUT', 36 | default: 0, 37 | testValues: [ 38 | {value: '30', expected: 30}, 39 | ], 40 | }, 41 | 'mapping.username': { 42 | env: 'MAPPING_USERNAME', 43 | default: 'uid', 44 | testValues: ['mail'], 45 | }, 46 | 'mapping.uid': { 47 | env: 'MAPPING_UID', 48 | default: 'uid', 49 | testValues: ['cn'], 50 | }, 51 | 'mapping.groups': { 52 | env: 'MAPPING_GROUPS', 53 | default: 'memberOf', 54 | testValues: ['groups'], 55 | }, 56 | 'mapping.extraFields': { 57 | env: 'MAPPING_EXTRAFIELDS', 58 | default: [], 59 | testValues: [ 60 | {value: 'uidNumber', expected: ['uidNumber']}, 61 | {value: 'uidNumber,gidNumber', expected: ['uidNumber', 'gidNumber']}, 62 | ], 63 | }, 64 | 'jwt.key': { 65 | env: 'JWT_KEY', 66 | default: 'secret', 67 | testValues: ['topsecret'], 68 | }, 69 | 'jwt.tokenLifetime': { 70 | env: 'JWT_TOKEN_LIFETIME', 71 | default: 28800, 72 | testValues: [ 73 | {value: '3600', expected: 3600}, 74 | ], 75 | }, 76 | 'tls.enabled': { 77 | env: 'TLS_ENABLED', 78 | default: true, 79 | testValues: [ 80 | {value: 'false', expected: false}, 81 | {value: 'true', expected: true}, 82 | {value: 'abc', expected: true}, 83 | ], 84 | }, 85 | 'tls.cert': { 86 | env: 'TLS_CERT_PATH', 87 | default: '/etc/ssl/kube-ldap/cert.pem', 88 | testValues: ['/etc/ssl/example.com/cert.pem'], 89 | }, 90 | 'tls.key': { 91 | env: 'TLS_KEY_PATH', 92 | default: '/etc/ssl/kube-ldap/key.pem', 93 | testValues: ['/etc/ssl/example.com/key.pem'], 94 | }, 95 | 'tls.ca': { 96 | env: 'TLS_CA_PATH', 97 | default: null, 98 | testValues: ['/etc/ssl/example.com/ca.pem'], 99 | }, 100 | 'prometheus.username': { 101 | env: 'PROMETHEUS_USERNAME', 102 | default: 'prometheus', 103 | testValues: [ 104 | {value: 'john.doe', expected: 'john.doe'}, 105 | {value: '', expected: null}, 106 | ], 107 | }, 108 | 'prometheus.password': { 109 | env: 'PROMETHEUS_PASSWORD', 110 | default: 'secret', 111 | testValues: [ 112 | {value: 'password', expected: 'password'}, 113 | {value: '', expected: null}, 114 | ], 115 | }, 116 | 'prometheus.nodejsProbeInterval': { 117 | env: 'PROMETHEUS_NODEJS_PROBE_INTERVAL', 118 | default: 10000, 119 | testValues: [ 120 | {value: '5000', expected: 5000}, 121 | ], 122 | }, 123 | 'port': { 124 | env: 'PORT', 125 | default: 8081, 126 | testValues: [ 127 | {value: '8443', expected: 8443}, 128 | ], 129 | }, 130 | }; 131 | 132 | for (let setting of Object.keys(fixtures)) { 133 | describe('config.' + setting, () => { 134 | test('test default value [' + fixtures[setting].default + ']', () => { 135 | delete process.env[fixtures[setting].env]; 136 | let config = getConfig(); // eslint-disable-line no-unused-vars 137 | expect(eval('config.' + setting)).toEqual(fixtures[setting].default); 138 | }); 139 | for (let testValue of fixtures[setting].testValues) { 140 | let value = testValue; 141 | let expected = testValue; 142 | if (testValue === Object(testValue)) { 143 | value = testValue.value; 144 | expected = testValue.expected; 145 | } 146 | test('test custom value [' + value + ']', () => { 147 | process.env[fixtures[setting].env] = value; 148 | let config = getConfig(); // eslint-disable-line no-unused-vars 149 | expect(eval('config.' + setting)).toEqual(expected); 150 | }); 151 | }; 152 | }); 153 | }; 154 | -------------------------------------------------------------------------------- /test/unit/ldap/authenticator.test.js: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | import Authenticator from '../../../src/ldap/authenticator'; 3 | import Client from '../../../src/ldap/client'; 4 | jest.mock('../../../src/ldap/client'); 5 | 6 | const fixtures = { 7 | username: 'john.doe', 8 | password: 'secret', 9 | groups: [ 10 | 'cn=test,dc=example,dc=com', 11 | ], 12 | attributeNames: [ 13 | 'uid', 14 | 'memberOf', 15 | ], 16 | }; 17 | 18 | let client = new Client(); 19 | const authenticator = new Authenticator(client, '', winston); 20 | 21 | beforeEach(() => { 22 | client.bindSuccess = true; 23 | client.searchResult = { 24 | uid: fixtures.username, 25 | memberOf: fixtures.groups, 26 | someAttribute: 'someValue', 27 | }; 28 | client.searchShouldReject = false; 29 | }); 30 | 31 | describe('Authenticator.authenticate()', () => { 32 | test('Returns true on valid user', () => { 33 | expect.hasAssertions(); 34 | return expect( 35 | authenticator.authenticate(fixtures.username, fixtures.password) 36 | ).resolves.toBe(true); 37 | }); 38 | 39 | test('Returns false on invalid user', () => { 40 | client.bindSuccess = false; 41 | 42 | expect.hasAssertions(); 43 | return expect( 44 | authenticator.authenticate(fixtures.username, fixtures.password) 45 | ).resolves.toBe(false); 46 | }); 47 | }); 48 | 49 | describe('Authenticator.getAttributes()', () => { 50 | test('Returns user object', () => { 51 | expect.hasAssertions(); 52 | return expect( 53 | authenticator.getAttributes(fixtures.username, fixtures.attributeNames) 54 | ).resolves.toEqual({ 55 | uid: fixtures.username, 56 | memberOf: fixtures.groups, 57 | }); 58 | }); 59 | 60 | test('Rejects on internal error (e.g. ldap error)', () => { 61 | client.searchShouldReject = true; 62 | 63 | expect.hasAssertions(); 64 | return expect( 65 | authenticator.getAttributes(fixtures.username, fixtures.attributeNames) 66 | ).rejects.toMatch('rejected by mock'); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /test/unit/ldap/canonicalizeDn.test.js: -------------------------------------------------------------------------------- 1 | import {canonicalizeDn} from '../../../src/ldap'; 2 | 3 | const fixtures = { 4 | validDn1: 'cn=test,dc=example,dc=com', 5 | validDn2: 'dc=com', 6 | invalidDn1: 'test,dc=example,dc=com', 7 | invalidDn2: 'test', 8 | }; 9 | 10 | describe('canonicalizeDn()', () => { 11 | test('Returns canonicalized on valid DNs', () => { 12 | expect(canonicalizeDn(fixtures.validDn1)).toBe('test'); 13 | expect(canonicalizeDn(fixtures.validDn2)).toBe('com'); 14 | }); 15 | 16 | test('Throws error on invalid DNs', () => { 17 | expect(() => { 18 | canonicalizeDn(fixtures.invalidDn1) 19 | }).toThrowError('invalid dn'); 20 | expect(() => { 21 | canonicalizeDn(fixtures.invalidDn2) 22 | }).toThrowError('invalid dn'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/unit/ldap/client.test.js: -------------------------------------------------------------------------------- 1 | import Client from '../../../src/ldap/client'; 2 | import {Client as Connection} from 'ldapts'; 3 | 4 | const fixtures = { 5 | basedn: 'dc=example,dc=com', 6 | binddn: 'uid=bind,dc=example,dc=com', 7 | bindpw: 'secret', 8 | username: 'john.doe', 9 | dn: 'uid=john.doe,dc=example,dc=com', 10 | password: 'secret', 11 | groups: [ 12 | 'cn=test,dc=example,dc=com', 13 | ], 14 | filter: '(uid=john.doe)', 15 | attributeNames: [ 16 | 'uid', 17 | 'memberOf', 18 | ], 19 | }; 20 | 21 | let connection = new Connection(); 22 | let connectionFactory = () => { 23 | return connection; 24 | }; 25 | let client = null; 26 | 27 | beforeEach(() => { 28 | connection.starttlsReturnsError = false; 29 | connection.bindReturnsError = false; 30 | connection.searchReturnsError = false; 31 | connection.searchEmitsError = false; 32 | connection.searchEmitsResult = true; 33 | connection.searchEmitsEndStatus = 0; 34 | connection.searchResult = { 35 | uid: fixtures.username, 36 | memberOf: fixtures.groups, 37 | }; 38 | client = new Client( 39 | connectionFactory, 40 | fixtures.basedn, 41 | fixtures.binddn, 42 | fixtures.bindpw, 43 | true 44 | ); 45 | }); 46 | 47 | describe('Client.bind()', () => { 48 | test('Resolves to true for successful bind', () => { 49 | expect.hasAssertions(); 50 | return expect( 51 | client.bind(fixtures.dn, fixtures.password) 52 | ).resolves.toBe(true); 53 | }); 54 | 55 | test('Resolves to false for unsuccessful bind', () => { 56 | connection.bindReturnsError = true; 57 | 58 | expect.hasAssertions(); 59 | return expect( 60 | client.bind(fixtures.dn, fixtures.password) 61 | ).resolves.toBe(false); 62 | }); 63 | }); 64 | 65 | describe('Client.search()', () => { 66 | test('Rejects on bind error', () => { 67 | connection.bindReturnsError = true; 68 | 69 | expect.hasAssertions(); 70 | return expect( 71 | client.search(fixtures.filter) 72 | ).rejects.toEqual(new Error('error by mock')); 73 | }); 74 | 75 | test('Rejects on search error', () => { 76 | connection.searchReturnsError = true; 77 | 78 | expect.hasAssertions(); 79 | return expect( 80 | client.search(fixtures.filter) 81 | ).rejects.toEqual(new Error('error by mock')); 82 | }); 83 | 84 | test('Rejects on empty result', () => { 85 | connection.searchReturnsResult = false; 86 | 87 | expect.hasAssertions(); 88 | return expect( 89 | client.search(fixtures.filter) 90 | ).rejects.toEqual(new Error( 91 | `no object found with filter [${fixtures.filter}]` 92 | )); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /test/unit/ldap/mapping.test.js: -------------------------------------------------------------------------------- 1 | import Mapping from '../../../src/ldap/mapping'; 2 | 3 | const fixtures = { 4 | mapping: { 5 | username: 'mail', 6 | uid: 'cn', 7 | groups: 'memberOf', 8 | extraFields: [ 9 | 'uidNumber', 10 | 'gidNumber', 11 | ], 12 | }, 13 | attributes: [ 14 | 'mail', 'cn', 'memberOf', 'uidNumber', 'gidNumber', 15 | ], 16 | ldapObject: { 17 | mail: 'john.doe@example.com', 18 | cn: 'john.doe', 19 | memberOf: [ 20 | 'cn=kubernetes,ou=groups,dc=example,dc=com', 21 | 'cn=user,ou=groups,dc=example,dc=com', 22 | ], 23 | uidNumber: 1, 24 | gidNumber: [10, 11], 25 | }, 26 | ldapObjectWithSingleGroup: { 27 | mail: 'john.doe@example.com', 28 | cn: 'john.doe', 29 | memberOf: 'cn=kubernetes,ou=groups,dc=example,dc=com', 30 | uidNumber: 1, 31 | gidNumber: [10, 11], 32 | }, 33 | ldapObjectWithoutGroup: { 34 | mail: 'john.doe@example.com', 35 | cn: 'john.doe', 36 | uidNumber: 1, 37 | gidNumber: [10, 11], 38 | }, 39 | kubernetesObject: { 40 | username: 'john.doe@example.com', 41 | uid: 'john.doe', 42 | groups: [ 43 | 'kubernetes', 44 | 'user', 45 | ], 46 | extra: { 47 | 'uidNumber': 1, 48 | 'gidNumber': [10, 11], 49 | }, 50 | }, 51 | kubernetesObjectWithSingleGroup: { 52 | username: 'john.doe@example.com', 53 | uid: 'john.doe', 54 | groups: [ 55 | 'kubernetes', 56 | ], 57 | extra: { 58 | 'uidNumber': 1, 59 | 'gidNumber': [10, 11], 60 | }, 61 | }, 62 | kubernetesObjectWithoutGroup: { 63 | username: 'john.doe@example.com', 64 | uid: 'john.doe', 65 | groups: [], 66 | extra: { 67 | 'uidNumber': 1, 68 | 'gidNumber': [10, 11], 69 | }, 70 | }, 71 | }; 72 | 73 | describe('Mapping.getLdapAttributes()', () => { 74 | test('returns array of all attributes', () => { 75 | let mapping = new Mapping( 76 | fixtures.mapping.username, 77 | fixtures.mapping.uid, 78 | fixtures.mapping.groups, 79 | fixtures.mapping.extraFields 80 | ); 81 | 82 | expect(mapping.getLdapAttributes()).toEqual(fixtures.attributes); 83 | }); 84 | 85 | test('returns array of all attributes if extraFields was empty', () => { 86 | let mapping = new Mapping( 87 | fixtures.mapping.username, 88 | fixtures.mapping.uid, 89 | fixtures.mapping.groups, 90 | [] 91 | ); 92 | 93 | expect(mapping.getLdapAttributes()).toEqual( 94 | fixtures.attributes.splice(0, 3) 95 | ); 96 | }); 97 | }); 98 | 99 | describe('Mapping.ldapToKubernetes()', () => { 100 | test('returns mapped object with attributes', () => { 101 | let mapping = new Mapping( 102 | fixtures.mapping.username, 103 | fixtures.mapping.uid, 104 | fixtures.mapping.groups, 105 | fixtures.mapping.extraFields 106 | ); 107 | 108 | expect( 109 | mapping.ldapToKubernetes(fixtures.ldapObject) 110 | ).toEqual(fixtures.kubernetesObject); 111 | }); 112 | 113 | test('handles when groups attribute is undefined', () => { 114 | let mapping = new Mapping( 115 | fixtures.mapping.username, 116 | fixtures.mapping.uid, 117 | fixtures.mapping.groups, 118 | fixtures.mapping.extraFields 119 | ); 120 | 121 | expect( 122 | mapping.ldapToKubernetes(fixtures.ldapObjectWithoutGroup) 123 | ).toEqual(fixtures.kubernetesObjectWithoutGroup); 124 | }); 125 | 126 | test('handles when groups attribute is a single object', () => { 127 | let mapping = new Mapping( 128 | fixtures.mapping.username, 129 | fixtures.mapping.uid, 130 | fixtures.mapping.groups, 131 | fixtures.mapping.extraFields 132 | ); 133 | 134 | expect( 135 | mapping.ldapToKubernetes(fixtures.ldapObjectWithSingleGroup) 136 | ).toEqual(fixtures.kubernetesObjectWithSingleGroup); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /test/unit/mock/index.js: -------------------------------------------------------------------------------- 1 | const getResponseMock = () => { 2 | return { 3 | send: jest.fn(), 4 | sendStatus: jest.fn(), 5 | set: jest.fn(), 6 | }; 7 | }; 8 | 9 | const getRequestMock = (body, header) => { 10 | return { 11 | header: header, 12 | body: body, 13 | get: (name) => { 14 | for (let key in header) { 15 | if (key.toLocaleLowerCase() === name.toLowerCase()) { 16 | return header[key]; 17 | } 18 | } 19 | }, 20 | }; 21 | }; 22 | 23 | export {getResponseMock, getRequestMock}; 24 | --------------------------------------------------------------------------------