Compare commits

..

6 commits

Author SHA1 Message Date
Eric Chiang
0a6c9e0984 Merge pull request #969 from ericchiang/cherry-pick-941
[cherry pick] server: fix localhost redirect validation for public clients
2017-06-15 10:06:53 -07:00
Eric Chiang
20c2ad5163 server: fix localhost redirect validation for public clients 2017-06-14 16:05:30 -07:00
rithu leena john
10253725de Merge pull request #7 from rithujohn191/ldap-password-check
connector/ldap: check for blank passwords and return error.
2017-05-01 17:06:55 -07:00
rithu leena john
65318da86e Merge pull request #6 from rithujohn191/api-resp-fic
server/api: return empty list of refresh tokens if user does not have any
2017-05-01 16:48:23 -07:00
rithu john
58eee98117 connector/ldap: check for blank passwords and return error. 2017-05-01 16:44:12 -07:00
rithu john
2dea454b26 server/api: return empty list of refresh tokens if user does not have any 2017-05-01 16:17:23 -07:00
872 changed files with 402334 additions and 73503 deletions

View file

@ -1,4 +1,3 @@
.github/ *
.gitpod.yml !_output/bin
bin/ !web
tmp/

View file

@ -1,21 +0,0 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.go]
indent_style = tab
[*.proto]
indent_size = 2
[{Makefile,*.mk}]
indent_style = tab
[{config.yaml.dist,config.dev.yaml}]
indent_size = 2

6
.envrc
View file

@ -1,6 +0,0 @@
if ! has nix_direnv_version || ! nix_direnv_version 1.5.0; then
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/1.5.0/direnvrc" "sha256-carKk9aUFHMuHt+IWh74hFj58nY4K3uywpZbwXX0BTI="
fi
use flake
dotenv_if_exists

View file

@ -1,2 +0,0 @@
[{*.yml,*.yaml}]
indent_size = 2

View file

@ -1,3 +0,0 @@
## Community Code of Conduct
This project follows the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/master/code-of-conduct.md).

View file

@ -1,102 +0,0 @@
name: 🐛 Bug report
description: Report a bug to help us improve Dex
body:
- type: markdown
attributes:
value: |
Thank you for submitting a bug report!
Please fill out the template below to make it easier to debug your problem.
If you are not sure if it is a bug or not, you can contact us via the available [support channels](https://github.com/dexidp/dex/issues/new/choose).
- type: checkboxes
attributes:
label: Preflight Checklist
description: Please ensure you've completed all of the following.
options:
- label: I agree to follow the [Code of Conduct](https://github.com/dexidp/dex/blob/master/.github/CODE_OF_CONDUCT.md) that this project adheres to.
required: true
- label: I have searched the [issue tracker](https://www.github.com/dexidp/dex/issues) for an issue that matches the one I want to file, without success.
required: true
- label: I am not looking for support or already pursued the available [support channels](https://github.com/dexidp/dex/issues/new/choose) without success.
required: true
- type: input
attributes:
label: Version
description: What version of Dex are you running?
placeholder: 2.29.0
validations:
required: true
- type: dropdown
attributes:
label: Storage Type
description: Which persistent storage type are you using?
options:
- etcd
- Kubernetes
- In-memory
- Postgres
- MySQL
- SQLite
validations:
required: true
- type: dropdown
attributes:
label: Installation Type
description: How did you install Dex?
options:
- Binary
- Official container image
- Official Helm chart
- Custom container image
- Custom Helm chart
- Other (specify below)
multiple: true
validations:
required: true
- type: textarea
attributes:
label: Expected Behavior
description: A clear and concise description of what you expected to happen.
validations:
required: true
- type: textarea
attributes:
label: Actual Behavior
description: A clear description of what actually happens.
validations:
required: true
- type: textarea
attributes:
label: Steps To Reproduce
description: Steps to reproduce the behavior if it is not self-explanatory.
placeholder: |
1. In this environment...
2. With this config...
3. Run '...'
4. See error...
- type: textarea
attributes:
label: Additional Information
description: Links? References? Anything that will give us more context about the issue that you are encountering!
- type: textarea
attributes:
label: Configuration
description: Contents of your configuration file (if relevant).
render: yaml
placeholder: |
issuer: http://127.0.0.1:5556/dex
storage:
# ...
connectors:
# ...
staticClients:
# ...
- type: textarea
attributes:
label: Logs
description: Dex application logs (if relevant).
render: shell

View file

@ -1,17 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: ❓ Ask a question
url: https://github.com/dexidp/dex/discussions/new?category=q-a
about: Ask and discuss questions with other Dex community members
- name: 📚 Documentation
url: https://dexidp.io/docs/
about: Check the documentation for help
- name: 💬 Slack channel
url: https://cloud-native.slack.com/messages/dexidp
about: Please ask and answer questions here
- name: 💡 Dex Enhancement Proposal
url: https://github.com/dexidp/dex/tree/master/enhancements/README.md
about: Open a proposal for significant architectural change

View file

@ -1,40 +0,0 @@
name: 🎉 Feature request
description: Suggest an idea for Dex
body:
- type: markdown
attributes:
value: |
Thank you for submitting a feature request!
Please describe what you would like to change/add and why in detail by filling out the template below.
If you are not sure if your request fits into Dex, you can contact us via the available [support channels](https://github.com/dexidp/dex/issues/new/choose).
- type: checkboxes
attributes:
label: Preflight Checklist
description: Please ensure you've completed all of the following.
options:
- label: I agree to follow the [Code of Conduct](https://github.com/dexidp/dex/blob/master/.github/CODE_OF_CONDUCT.md) that this project adheres to.
required: true
- label: I have searched the [issue tracker](https://www.github.com/dexidp/dex/issues) for an issue that matches the one I want to file, without success.
required: true
- type: textarea
attributes:
label: Problem Description
description: A clear and concise description of the problem you are seeking to solve with this feature request.
validations:
required: true
- type: textarea
attributes:
label: Proposed Solution
description: A clear and concise description of what would you like to happen.
validations:
required: true
- type: textarea
attributes:
label: Alternatives Considered
description: A clear and concise description of any alternative solutions or features you've considered.
- type: textarea
attributes:
label: Additional Information
description: Add any other context about the problem here.

View file

@ -1,35 +0,0 @@
<!--
Thank you for sending a pull request! Here are some tips for contributors:
1. Fill the description template below.
2. Sign a DCO (if you haven't already signed it).
3. Include appropriate tests (if necessary). Make sure that all CI checks passed.
4. If the Pull Request is a work in progress, make use of GitHub's "Draft PR" feature and mark it as such.
-->
#### Overview
<!-- Describe your changes briefly here. -->
#### What this PR does / why we need it
<!--
- Please state in detail why we need this PR and what it solves.
- If your PR closes some of the existing issues, please add links to them here.
Mentioned issues will be automatically closed.
Usage: "Closes #<issue number>", or "Closes (paste link of issue)"
-->
#### Special notes for your reviewer
#### Does this PR introduce a user-facing change?
<!--
If no, just write "NONE" in the release-note block below.
If yes, a release note is required:
Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".
-->
```release-note
```

24
.github/SECURITY.md vendored
View file

@ -1,24 +0,0 @@
# Security Policy
## Reporting a vulnerability
To report a vulnerability, send an email to [cncf-dex-maintainers@lists.cncf.io](mailto:cncf-dex-maintainers@lists.cncf.io)
detailing the issue and steps to reproduce. The reporter(s) can expect a
response within 48 hours acknowledging the issue was received. If a response is
not received within 48 hours, please reach out to any maintainer directly
to confirm receipt of the issue.
## Review Process
Once a maintainer has confirmed the relevance of the report, a draft security
advisory will be created on Github. The draft advisory will be used to discuss
the issue with maintainers, the reporter(s).
If the reporter(s) wishes to participate in this discussion, then provide
reporter Github username(s) to be invited to the discussion. If the reporter(s)
does not wish to participate directly in the discussion, then the reporter(s)
can request to be updated regularly via email.
If the vulnerability is accepted, a timeline for developing a patch, public
disclosure, and patch release will be determined. The reporter(s) are expected
to participate in the discussion of the timeline and abide by agreed upon dates
for public disclosure.

View file

@ -1,30 +0,0 @@
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
labels:
- "area/dependencies"
schedule:
interval: "daily"
- package-ecosystem: "gomod"
directory: "/api/v2"
labels:
- "area/dependencies"
schedule:
interval: "daily"
- package-ecosystem: "docker"
directory: "/"
labels:
- "area/dependencies"
schedule:
interval: "daily"
- package-ecosystem: "github-actions"
directory: "/"
labels:
- "area/dependencies"
schedule:
interval: "daily"

30
.github/release.yml vendored
View file

@ -1,30 +0,0 @@
changelog:
exclude:
labels:
- release-note/ignore
categories:
- title: Exciting New Features 🎉
labels:
- kind/feature
- release-note/new-feature
- title: Enhancements 🚀
labels:
- kind/enhancement
- release-note/enhancement
- title: Bug Fixes 🐛
labels:
- kind/bug
- release-note/bug-fix
- title: Breaking Changes 🛠
labels:
- release-note/breaking-change
- title: Deprecations ❌
labels:
- release-note/deprecation
- title: Dependency Updates ⬆️
labels:
- area/dependencies
- release-note/dependency-update
- title: Other Changes
labels:
- "*"

View file

@ -1,97 +0,0 @@
name: Artifacts
on:
push:
branches:
- master
tags:
- v[0-9]+.[0-9]+.[0-9]+
pull_request:
jobs:
container-images:
name: Container images
runs-on: ubuntu-latest
strategy:
matrix:
variant:
- alpine
- distroless
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Gather metadata
id: meta
uses: docker/metadata-action@v4
with:
images: |
ghcr.io/dexidp/dex
dexidp/dex
flavor: |
latest = false
tags: |
type=ref,event=branch,enable=${{ matrix.variant == 'alpine' }}
type=ref,event=pr,enable=${{ matrix.variant == 'alpine' }}
type=semver,pattern={{raw}},enable=${{ matrix.variant == 'alpine' }}
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && matrix.variant == 'alpine' }}
type=ref,event=branch,suffix=-${{ matrix.variant }}
type=ref,event=pr,suffix=-${{ matrix.variant }}
type=semver,pattern={{raw}},suffix=-${{ matrix.variant }}
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }},suffix=-${{ matrix.variant }}
labels: |
org.opencontainers.image.documentation=https://dexidp.io/docs/
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
with:
platforms: all
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ github.token }}
if: github.event_name == 'push'
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
if: github.event_name == 'push'
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le
# cache-from: type=gha
# cache-to: type=gha,mode=max
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
build-args: |
BASE_IMAGE=${{ matrix.variant }}
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
COMMIT_HASH=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
labels: ${{ steps.meta.outputs.labels }}
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@0.6.1
with:
image-ref: "ghcr.io/dexidp/dex:${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}"
format: "sarif"
output: "trivy-results.sarif"
if: github.event_name == 'push'
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: "trivy-results.sarif"
if: github.event_name == 'push'

View file

@ -1,18 +0,0 @@
name: PR Checks
on:
pull_request:
types: [opened, labeled, unlabeled, synchronize]
jobs:
release-label:
name: Release note label
runs-on: ubuntu-latest
steps:
- name: Check minimum labels
uses: mheap/github-action-required-labels@v2
with:
mode: minimum
count: 1
labels: "release-note/ignore, kind/feature, release-note/new-feature, kind/enhancement, release-note/enhancement, kind/bug, release-note/bug-fix, release-note/breaking-change, release-note/deprecation, area/dependencies, release-note/dependency-update"

View file

@ -1,129 +0,0 @@
name: CI
on:
push:
branches:
- master
pull_request:
jobs:
build:
name: Build
runs-on: ubuntu-latest
env:
GOFLAGS: -mod=readonly
services:
postgres:
image: postgres:10.8
ports:
- 5432
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
postgres-ent:
image: postgres:10.8
ports:
- 5432
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
mysql:
image: mysql:5.7
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: dex
ports:
- 3306
options: --health-cmd "mysql -proot -e \"show databases;\"" --health-interval 10s --health-timeout 5s --health-retries 5
mysql-ent:
image: mysql:5.7
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: dex
ports:
- 3306
options: --health-cmd "mysql -proot -e \"show databases;\"" --health-interval 10s --health-timeout 5s --health-retries 5
etcd:
image: gcr.io/etcd-development/etcd:v3.5.0
ports:
- 2379
env:
ETCD_LISTEN_CLIENT_URLS: http://0.0.0.0:2379
ETCD_ADVERTISE_CLIENT_URLS: http://0.0.0.0:2379
options: --health-cmd "ETCDCTL_API=3 etcdctl --endpoints http://localhost:2379 endpoint health" --health-interval 10s --health-timeout 5s --health-retries 5
keystone:
image: openio/openstack-keystone:rocky
ports:
- 5000
- 35357
options: --health-cmd "curl --fail http://localhost:5000/v3" --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: 1.18
- name: Checkout code
uses: actions/checkout@v3
- name: Start services
run: docker-compose -f docker-compose.test.yaml up -d
- name: Create kind cluster
uses: helm/kind-action@v1.3.0
with:
version: v0.11.1
node_image: kindest/node:v1.19.11@sha256:07db187ae84b4b7de440a73886f008cf903fcf5764ba8106a9fd5243d6f32729
- name: Download tool dependencies
run: make deps
- name: Test
run: make testall
env:
DEX_MYSQL_DATABASE: dex
DEX_MYSQL_USER: root
DEX_MYSQL_PASSWORD: root
DEX_MYSQL_HOST: 127.0.0.1
DEX_MYSQL_PORT: ${{ job.services.mysql.ports[3306] }}
DEX_MYSQL_ENT_DATABASE: dex
DEX_MYSQL_ENT_USER: root
DEX_MYSQL_ENT_PASSWORD: root
DEX_MYSQL_ENT_HOST: 127.0.0.1
DEX_MYSQL_ENT_PORT: ${{ job.services.mysql-ent.ports[3306] }}
DEX_POSTGRES_DATABASE: postgres
DEX_POSTGRES_USER: postgres
DEX_POSTGRES_PASSWORD: postgres
DEX_POSTGRES_HOST: localhost
DEX_POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }}
DEX_POSTGRES_ENT_DATABASE: postgres
DEX_POSTGRES_ENT_USER: postgres
DEX_POSTGRES_ENT_PASSWORD: postgres
DEX_POSTGRES_ENT_HOST: localhost
DEX_POSTGRES_ENT_PORT: ${{ job.services.postgres-ent.ports[5432] }}
DEX_ETCD_ENDPOINTS: http://localhost:${{ job.services.etcd.ports[2379] }}
DEX_LDAP_HOST: localhost
DEX_LDAP_PORT: 389
DEX_LDAP_TLS_PORT: 636
DEX_KEYSTONE_URL: http://localhost:${{ job.services.keystone.ports[5000] }}
DEX_KEYSTONE_ADMIN_URL: http://localhost:${{ job.services.keystone.ports[35357] }}
DEX_KEYSTONE_ADMIN_USER: demo
DEX_KEYSTONE_ADMIN_PASS: DEMO_PASS
DEX_KUBERNETES_CONFIG_PATH: ~/.kube/config
- name: Lint
run: make lint
# Ensure proto generation doesn't depend on external packages.
- name: Verify proto
run: make verify-proto

View file

@ -1,67 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ master, v1 ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ master ]
schedule:
- cron: '28 10 * * 6'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [ 'go' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2

View file

@ -1,111 +0,0 @@
name: Docker
on:
# push:
# branches:
# - master
# tags:
# - v[0-9]+.[0-9]+.[0-9]+
pull_request:
jobs:
docker:
name: Docker
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Calculate Docker image tags
id: tags
env:
DOCKER_IMAGES: "ghcr.io/dexidp/dex dexidp/dex"
run: |
case $GITHUB_REF in
refs/tags/*) VERSION=${GITHUB_REF#refs/tags/};;
refs/heads/*) VERSION=$(echo ${GITHUB_REF#refs/heads/} | sed -r 's#/+#-#g');;
refs/pull/*) VERSION=pr-${{ github.event.number }};;
*) VERSION=sha-${GITHUB_SHA::8};;
esac
TAGS=()
for image in $DOCKER_IMAGES; do
TAGS+=("${image}:${VERSION}")
if [[ "${{ github.event.repository.default_branch }}" == "$VERSION" ]]; then
TAGS+=("${image}:latest")
fi
done
echo ::set-output name=version::${VERSION}
echo ::set-output name=tags::$(IFS=,; echo "${TAGS[*]}")
echo ::set-output name=commit_hash::${GITHUB_SHA::8}
echo ::set-output name=build_date::$(git show -s --format=%cI)
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
with:
platforms: all
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
with:
install: true
version: latest
# TODO: Remove driver-opts once fix is released docker/buildx#386
driver-opts: image=moby/buildkit:master
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ github.token }}
if: github.event_name == 'push'
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
if: github.event_name == 'push'
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le
# cache-from: type=gha
# cache-to: type=gha,mode=max
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.tags.outputs.tags }}
build-args: |
VERSION=${{ steps.tags.outputs.version }}
COMMIT_HASH=${{ steps.tags.outputs.commit_hash }}
BUILD_DATE=${{ steps.tags.outputs.build_date }}
labels: |
org.opencontainers.image.title=${{ github.event.repository.name }}
org.opencontainers.image.description=${{ github.event.repository.description }}
org.opencontainers.image.url=${{ github.event.repository.html_url }}
org.opencontainers.image.source=${{ github.event.repository.clone_url }}
org.opencontainers.image.version=${{ steps.tags.outputs.version }}
org.opencontainers.image.created=${{ steps.tags.outputs.build_date }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.licenses=${{ github.event.repository.license.spdx_id }}
org.opencontainers.image.documentation=https://dexidp.io/docs/
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@0.6.1
with:
image-ref: "ghcr.io/dexidp/dex:${{ steps.tags.outputs.version }}"
format: "template"
template: "@/contrib/sarif.tpl"
output: "trivy-results.sarif"
if: github.event_name == 'push'
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: "trivy-results.sarif"
if: github.event_name == 'push'

10
.gitignore vendored
View file

@ -1,7 +1,3 @@
/.direnv/ bin
/.idea/ dist
/bin/ _output
/config.yaml
/docker-compose.override.yaml
/var/
/vendor/

View file

@ -1,3 +0,0 @@
tasks:
- init: go get && go build ./... && go test ./... && make
command: go run

View file

@ -1,90 +0,0 @@
run:
timeout: 4m
linters-settings:
depguard:
list-type: blacklist
include-go-root: true
packages:
- io/ioutil
packages-with-error-message:
- io/ioutil: "The 'io/ioutil' package is deprecated. Use corresponding 'os' or 'io' functions instead."
gci:
local-prefixes: github.com/dexidp/dex
goimports:
local-prefixes: github.com/dexidp/dex
linters:
disable-all: true
enable:
- bodyclose
- deadcode
- depguard
- dogsled
- exhaustive
- exportloopref
- gci
- gochecknoinits
- gocritic
- gofmt
- gofumpt
- goimports
- goprintffuncname
- gosimple
- govet
- ineffassign
- misspell
- nakedret
- nolintlint
- prealloc
- revive
- rowserrcheck
- sqlclosecheck
- staticcheck
- structcheck
- stylecheck
- tparallel
- unconvert
- unparam
- unused
- varcheck
- whitespace
# Disable temporarily until everything works with Go 1.18
# - typecheck
# TODO: fix linter errors before enabling
# - exhaustivestruct
# - gochecknoglobals
# - errorlint
# - gocognit
# - godot
# - nlreturn
# - noctx
# - wrapcheck
# TODO: fix linter errors before enabling (from original config)
# - dupl
# - errcheck
# - goconst
# - gocyclo
# - gosec
# - lll
# - scopelint
# unused
# - goheader
# - gomodguard
# don't enable:
# - asciicheck
# - funlen
# - godox
# - goerr113
# - gomnd
# - interfacer
# - maligned
# - nestif
# - testpackage
# - wsl

26
.travis.yml Normal file
View file

@ -0,0 +1,26 @@
language: go
sudo: required
go:
- 1.7.5
- 1.8
services:
- postgresql
env:
- DEX_POSTGRES_DATABASE=postgres DEX_POSTGRES_USER=postgres DEX_POSTGRES_HOST="localhost" DEX_LDAP_TESTS=1 DEBIAN_FRONTEND=noninteractive
install:
- go get -u github.com/golang/lint/golint
- sudo -E apt-get install -y --force-yes slapd time ldap-utils
- sudo /etc/init.d/slapd stop
script:
- make testall
notifications:
email: false

View file

@ -1,15 +0,0 @@
# Adopters
This is a list of production adopters of Dex (in alphabetical order):
- [Aspect](https://www.aspect.com/) uses Dex for authenticating users across their Kubernetes infrastructure (using Kubernetes OIDC support).
- [Banzai Cloud](https://banzaicloud.com) is using Dex for authenticating to its Pipeline control plane and also to authenticate users against provisioned Kubernetes clusters (via Kubernetes OIDC support).
- [Chef](https://chef.io) uses Dex for authenticating users in [Chef Automate](https://automate.chef.io/). The code is Open Source, available at [`github.com/chef/automate`](https://github.com/chef/automate).
- [Elastisys](https://elastisys.com) uses Dex for authentication in their [Compliant Kubernetes](https://compliantkubernetes.io) distribution, including SSO to the custom dashboard, Grafana, Kibana, and Harbor.
- [Flant](https://flant.com) uses Dex for providing access to core components of [Managed Kubernetes as a Service](https://flant.com/services/managed-kubernetes-as-a-service), integration with various authentication providers, plugging custom applications.
- [JuliaBox](https://juliabox.com/) is leveraging federated OIDC provided by Dex for authenticating users to their compute infrastructure based on Kubernetes.
- [Kasten](https://www.kasten.io) is using Dex for authenticating access to the dashboard of [K10](https://www.kasten.io/product/), a Kubernetes-native platform for backup, disaster recovery and mobility of Kubernetes applications. K10 is widely used by a variety of customers including large enterprises, financial services, design firms, and IT companies.
- [Kyma](https://kyma-project.io) is using Dex to authenticate access to Kubernetes API server (even for managed Kubernetes like Google Kubernetes Engine or Azure Kubernetes Service) and for protecting web UI of [Kyma Console](https://github.com/kyma-project/console) and other UIs integrated in Kyma ([Grafana](https://github.com/grafana/grafana), [Loki](https://github.com/grafana/loki), and [Jaeger](https://github.com/jaegertracing/jaeger)). Kyma is an open-source project ([`github.com/kyma-project`](https://github.com/kyma-project/kyma)) designed natively on Kubernetes, that allows you to extend and customize your applications in a quick and modern way, using serverless computing or microservice architecture.
- [Pusher](https://pusher.com) uses Dex for authenticating users across their Kubernetes infrastructure (using Kubernetes OIDC support) in conjunction with the [OAuth2 Proxy](https://github.com/pusher/oauth2_proxy) for protecting web UIs.
- [Pydio](https://pydio.com/) Pydio Cells is an open source sync & share platform written in Go. Cells is using Dex as an OIDC service for authentication and authorizations. Check out [Pydio Cells repository](https://github.com/pydio/cells) for more information and/or to contribute.
- [sigstore](https://sigstore.dev) uses Dex for authentication in their public Fulcio instance, which is a certificate authority for code signing certificates bound to OIDC-based identities.

View file

View file

@ -1,72 +1,23 @@
ARG BASE_IMAGE=alpine FROM alpine:3.4
FROM golang:1.18.4-alpine3.15 AS builder MAINTAINER Ed Rooth <ed.rooth@coreos.com>
MAINTAINER Eric Chiang <eric.chiang@coreos.com>
WORKDIR /usr/local/src/dex MAINTAINER Rithu John <rithu.john@coreos.com>
RUN apk add --no-cache --update alpine-sdk ca-certificates openssl
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT=""
ENV GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOARM=${TARGETVARIANT}
ARG GOPROXY
COPY go.mod go.sum ./
COPY api/v2/go.mod api/v2/go.sum ./api/v2/
RUN go mod download
COPY . .
RUN make release-binary
FROM alpine:3.16.2 AS stager
RUN mkdir -p /var/dex
RUN mkdir -p /etc/dex
COPY config.docker.yaml /etc/dex/
FROM alpine:3.16.2 AS gomplate
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
ENV GOMPLATE_VERSION=v3.11.2
RUN wget -O /usr/local/bin/gomplate \
"https://github.com/hairyhenderson/gomplate/releases/download/${GOMPLATE_VERSION}/gomplate_${TARGETOS:-linux}-${TARGETARCH:-amd64}${TARGETVARIANT}" \
&& chmod +x /usr/local/bin/gomplate
# For Dependabot to detect base image versions
FROM alpine:3.16.2 AS alpine
FROM gcr.io/distroless/static:latest AS distroless
FROM $BASE_IMAGE
# Dex connectors, such as GitHub and Google logins require root certificates. # Dex connectors, such as GitHub and Google logins require root certificates.
# Proper installations should manage those certificates, but it's a bad user # Proper installations should manage those certificates, but it's a bad user
# experience when this doesn't work out of the box. # experience when this doesn't work out of the box.
# #
# See https://go.dev/src/crypto/x509/root_linux.go for Go root CA bundle locations. # OpenSSL is required so wget can query HTTPS endpoints for health checking.
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt RUN apk add --update ca-certificates openssl
COPY --from=stager --chown=1001:1001 /var/dex /var/dex COPY _output/bin/dex /usr/local/bin/dex
COPY --from=stager --chown=1001:1001 /etc/dex /etc/dex
# Copy module files for CVE scanning / dependency analysis. # Import frontend assets and set the correct CWD directory so the assets
COPY --from=builder /usr/local/src/dex/go.mod /usr/local/src/dex/go.sum /usr/local/src/dex/ # are in the default path.
COPY --from=builder /usr/local/src/dex/api/v2/go.mod /usr/local/src/dex/api/v2/go.sum /usr/local/src/dex/api/v2/ COPY web /web
WORKDIR /
COPY --from=builder /go/bin/dex /usr/local/bin/dex ENTRYPOINT ["dex"]
COPY --from=builder /go/bin/docker-entrypoint /usr/local/bin/docker-entrypoint
COPY --from=builder /usr/local/src/dex/web /srv/dex/web
COPY --from=gomplate /usr/local/bin/gomplate /usr/local/bin/gomplate CMD ["version"]
USER 1001:1001
ENTRYPOINT ["/usr/local/bin/docker-entrypoint"]
CMD ["dex", "serve", "/etc/dex/config.docker.yaml"]

117
Documentation/api.md Normal file
View file

@ -0,0 +1,117 @@
# The dex API
Dex provides a [gRPC][grpc] service for programmatic modification of dex's state. The API is intended to expose hooks for management applications and is not expected to be used by most installations.
This document is an overview of how to interact with the API.
## Configuration
Admins that wish to expose the gRPC service must add the following entry to the dex config file. This option is off by default.
```
grpc:
# Cannot be the same address as an HTTP(S) service.
addr: 127.0.0.1:5557
# Server certs. If TLS credentials aren't provided dex will generate self-signed ones.
tlsCert: /etc/dex/grpc.crt
tlsKey: /etc/dex/grpc.key
# Client auth CA.
tlsClientCA: /etc/dex/client.crt
```
## Generating clients
gRPC is a suite of tools for generating client and server bindings from a common declarative language. The canonical schema for dex's API can be found in the source tree at [`api/api.proto`][api-proto]. Go bindings are generated and maintained in the same directory for internal use.
To generate a client for your own project install [`protoc`][protoc], install a protobuf generator for your project's language, and download the `api.proto` file. An example for a Go project:
```
# Install protoc-gen-go.
$ go get -u github.com/golang/protobuf/{proto,protoc-gen-go}
# Download api.proto for a given version.
$ DEX_VERSION=v2.0.0-alpha.5
$ wget https://raw.githubusercontent.com/coreos/dex/${DEX_VERSION}/api/api.proto
# Generate the Go client bindings.
$ protoc --go_out=import_path=dexapi:. api.proto
```
Client programs can then be written using the generated code. A Go client which uses dex's internally generated code might look like the following:
__NOTE:__ Because dex has the `google.golang.org/grpc` package in its `vendor` directory, gRPC code in `github.com/coreos/dex/api` refers to the vendored copy, not copies in a developers GOPATH. Clients must either regenerate the gRPC Go code or vendor dex and remove its `vendor` directory to run this program.
```
package main
import (
"context"
"fmt"
"log"
"github.com/coreos/dex/api"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
func newDexClient(hostAndPort, caPath string) (api.DexClient, error) {
creds, err := credentials.NewClientTLSFromFile(caPath, "")
if err != nil {
return nil, fmt.Errorf("load dex cert: %v", err)
}
conn, err := grpc.Dial(hostAndPort, grpc.WithTransportCredentials(creds))
if err != nil {
return nil, fmt.Errorf("dail: %v", err)
}
return api.NewDexClient(conn), nil
}
func main() {
client, err := newDexClient("127.0.0.1:5557", "/etc/dex/grpc.crt")
if err != nil {
log.Fatalf("failed creating dex client: %v ", err)
}
req := &api.CreateClientReq{
Client: &api.Client{
Id: "example-app",
Name: "Example App",
Secret: "ZXhhbXBsZS1hcHAtc2VjcmV0",
RedirectUris: []string{"http://127.0.0.1:5555/callback"},
},
}
if _, err := client.CreateClient(context.TODO(), req); err != nil {
log.Fatalf("failed creating oauth2 client: %v", err)
}
}
```
A clear working example of the Dex gRPC client can be found [here][../examples/grpc-client/README.md].
## Authentication and access control
The dex API does not provide any authentication or authorization beyond TLS client auth.
Projects that wish to add access controls on top of the existing API should build apps which perform such checks. For example to provide a "Change password" screen, a client app could use dex's OpenID Connect flow to authenticate an end user, then call dex's API to update that user's password.
## dexctl?
Dex does not ship with a command line tool for interacting with the API. Command line tools are useful but hard to version, easy to design poorly, and expose another interface which can never be changed in the name of compatibility.
While the dex team would be open to re-implementing `dexctl` for v2 a majority of the work is writing a design document, not the actual programming effort.
## Why not REST or gRPC Gateway?
Between v1 and v2, dex switched from REST to gRPC. This largely stemmed from problems generating documentation, client bindings, and server frameworks that adequately expressed REST semantics. While [Google APIs][google-apis], [Open API/Swagger][open-api], and [gRPC Gateway][grpc-gateway] were evaluated, they often became clunky when trying to use specific HTTP error codes or complex request bodies. As a result, v2's API is entirely gRPC.
Many arguments _against_ gRPC cite short term convenience rather than production use cases. Though this is a recognized shortcoming, dex already implements many features for developer convenience. For instance, users who wish to manually edit clients during testing can use the `staticClients` config field instead of the API.
[grpc]: http://www.grpc.io/
[api-proto]: ../api/api.proto
[protoc]: https://github.com/google/protobuf/releases
[protoc-gen-go]: https://github.com/golang/protobuf
[google-apis]: https://github.com/google/apis-client-generator
[open-api]: https://openapis.org/
[grpc-gateway]: https://github.com/grpc-ecosystem/grpc-gateway

View file

@ -0,0 +1,72 @@
# Custom scopes, claims and client features
This document describes the set of OAuth2 and OpenID Connect features implemented by dex.
## Scopes
The following is the exhaustive list of scopes supported by dex:
| Name | Description |
| ---- | ------------|
| `openid` | Required scope for all login requests. |
| `email` | ID token claims should include the end user's email and if that email was verified by an upstream provider. |
| `profile` | ID token claims should include the username of the end user. |
| `groups` | ID token claims should include a list of groups the end user is a member of. |
| `offline_access` | Token response should include a refresh token. Doesn't work in combinations with some connectors, notability the [SAML connector][saml-connector] ignores this scope. |
| `audience:server:client_id:( client-id )` | Dynamic scope indicating that the ID token should be issued on behalf of another client. See the _"Cross-client trust and authorized party"_ section below. |
## Custom claims
Beyond the [required OpenID Connect claims][core-claims], and a handful of [standard claims][standard-claims], dex implements the following non-standard claims.
| Name | Description |
| ---- | ------------|
| `groups` | A list of strings representing the groups a user is a member of. |
| `email` | The email of the user. |
| `email_verified` | If the upstream provider has verified the email. |
| `name` | User's display name. |
## Cross-client trust and authorized party
Dex has the ability to issue ID tokens to clients on behalf of other clients. In OpenID Connect terms, this means the ID token's `aud` (audience) claim being a different client ID than the client that performed the login.
For example, this feature could be used to allow a web app to generate an ID token on behalf of a command line tool:
```yaml
staticClients:
- id: web-app
redirectURIs:
- 'https://web-app.example.com/callback'
name: 'Web app'
secret: web-app-secret
- id: cli-app
redirectURIs:
- 'https://cli-app.example.com/callback'
name: 'Command line tool'
secret: cli-app-secret
# The command line tool lets the web app issue ID tokens on its behalf.
trustedPeers:
- web-app
```
Note that the command line tool must explicitly trust the web app using the `trustedPeers` field. The web app can then use the following scope to request an ID token that's issued for the command line tool.
```
audience:server:client_id:cli-app
```
The ID token claims will then include the following audience and authorized party:
```
{
"aud": "cli-app",
"azp": "web-app",
"email": "foo@bar.com",
// other claims...
}
```
[saml-connector]: saml-connector.md
[core-claims]: https://openid.net/specs/openid-connect-core-1_0.html#IDToken
[standard-claims]: https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims

View file

@ -0,0 +1,35 @@
# Managing dependencies
Dex uses [glide][glide] and [glide-vc][glide-vc] to manage its [`vendor` directory][go-vendor]. A recent version of these are preferred but dex doesn't require any bleeding edge features. Either install these tools using `go get` or take an opportunity to update to a more recent version.
```
go get -u github.com/Masterminds/glide
go get -u github.com/sgotti/glide-vc
```
To add a new dependency to dex or update an existing one:
* Make changes to dex's source code importing the new dependency.
* Edit `glide.yaml` to include the new dependency at a given commit SHA or change a SHA.
* Add all transitive dependencies of the package to prevent unpinned packages.
Tests will fail if transitive dependencies aren't included.
Once `glide.yaml` describes the desired state use `make` to update `glide.lock` and `vendor`. This calls both `glide` and `glide-vc` with the set of flags that dex requires.
```
make revendor
```
When composing commits make sure that updates to `vendor` are in a separate commit from the main changes. GitHub's UI makes commits with a large number of changes unreviewable.
Commit histories should look like the following:
```
connector/ldap: add a LDAP connector
vendor: revendor
```
[glide]: https://github.com/Masterminds/glide
[glide-vc]: https://github.com/sgotti/glide-vc
[go-vendor]: https://golang.org/cmd/go/#hdr-Vendor_Directories

View file

@ -0,0 +1,138 @@
# Running integration tests
## Kubernetes
Kubernetes tests will only run if the `DEX_KUBECONFIG` environment variable is set.
```
$ export DEX_KUBECONFIG=~/.kube/config
$ go test -v -i ./storage/kubernetes
$ go test -v ./storage/kubernetes
```
Because third party resources creation isn't synchronized it's expected that the tests fail the first time. Fear not, and just run them again.
## Postgres
Running database tests locally require:
* A systemd based Linux distro.
* A recent version of [rkt](https://github.com/coreos/rkt) installed.
The `standup.sh` script in the SQL directory is used to run databases in containers with systemd daemonizing the process.
```
$ sudo ./storage/sql/standup.sh create postgres
Starting postgres. To view progress run
journalctl -fu dex-postgres
Running as unit dex-postgres.service.
To run tests export the following environment variables:
export DEX_POSTGRES_DATABASE=postgres; export DEX_POSTGRES_USER=postgres; export DEX_POSTGRES_PASSWORD=postgres; export DEX_POSTGRES_HOST=172.16.28.3:5432
```
Exporting the variables will cause the database tests to be run, rather than skipped.
```
$ # sqlite3 takes forever to compile, be sure to install test dependencies
$ go test -v -i ./storage/sql
$ go test -v ./storage/sql
```
When you're done, tear down the unit using the `standup.sh` script.
```
$ sudo ./storage/sql/standup.sh destroy postgres
```
## LDAP
To run LDAP tests locally, you require a container running OpenLDAP.
Run OpenLDAP docker image:
```
$ sudo docker run --hostname ldap.example.org --name openldap-container --detach osixia/openldap:1.1.6
```
By default TLS is enabled and a certificate is created with the container hostname, which in this case is "ldap.example.org". It will create an empty LDAP for the company Example Inc. and the domain example.org. By default the admin has the password admin.
Add new users and groups (sample .ldif file included at the end):
```
$ sudo docker exec openldap-container ldapadd -x -D "cn=admin,dc=example,dc=org" -w admin -f <path to .ldif> -h ldap.example.org -ZZ
```
Verify that the added entries are in your directory with ldapsearch :
```
$ sudo docker exec openldap-container ldapsearch -x -h localhost -b dc=example,dc=org -D "cn=admin,dc=example,dc=org" -w admin
```
The .ldif file should contain seed data. Example file contents:
```
dn: cn=Test1,dc=example,dc=org
objectClass: organizationalRole
cn: Test1
dn: cn=Test2,dc=example,dc=org
objectClass: organizationalRole
cn: Test2
dn: ou=groups,dc=example,dc=org
ou: groups
objectClass: top
objectClass: organizationalUnit
dn: cn=tstgrp,ou=groups,dc=example,dc=org
objectClass: top
objectClass: groupOfNames
member: cn=Test1,dc=example,dc=org
cn: tstgrp
```
## SAML
### Okta
The Okta identity provider supports free accounts for developers to test their implementation against. This document describes configuring an Okta application to test dex's SAML connector.
First, [sign up for a developer account][okta-sign-up]. Then, to create a SAML application:
* Go to the admin screen.
* Click "Add application"
* Click "Create New App"
* Choose "SAML 2.0" and press "Create"
* Configure SAML
* Enter `http://127.0.0.1:5556/dex/callback` for "Single sign on URL"
* Enter `http://127.0.0.1:5556/dex/callback` for "Audience URI (SP Entity ID)"
* Under "ATTRIBUTE STATEMENTS (OPTIONAL)" add an "email" and "name" attribute. The values should be something like `user:email` and `user:firstName`, respectively.
* Under "GROUP ATTRIBUTE STATEMENTS (OPTIONAL)" add a "groups" attribute. Use the "Regexp" filter `.*`.
After the application's created, assign yourself to the app.
* "Applications" > "Applications"
* Click on your application then under the "People" tab press the "Assign to People" button and add yourself.
At the app, go to the "Sign On" tab and then click "View Setup Instructions". Use those values to fill out the following connector in `examples/config-dev.yaml`.
```yaml
connectors:
- type: saml
id: saml
name: Okta
config:
ssoURL: ( "Identity Provider Single Sign-On URL" )
caData: ( base64'd value of "X.509 Certificate" )
redirectURI: http://127.0.0.1:5556/dex/callback
usernameAttr: name
emailAttr: email
groupsAttr: groups
```
Start both dex and the example app, and try logging in (requires not requesting a refresh token).
[okta-sign-up]: https://www.okta.com/developer/signup/

View file

@ -0,0 +1,81 @@
# Releases
Making a dex release involves:
* Tagging a git commit and pushing the tag to GitHub.
* Building and pushing a Docker image.
This requires the following tools.
* Docker
And the following permissions.
* Push access to the github.com/coreos/dex git repo.
* Push access to the quay.io/coreos/dex Docker repo.
## Tagging the release
Make sure you've [uploaded your GPG key](https://github.com/settings/keys) and
configured git to [use that signing key](
https://git-scm.com/book/en/v2/Git-Tools-Signing-Your-Work) either globally or
for the Dex repo. Note that the email the key is issued for must be the email
you use for git.
```
git config [--global] user.signingkey "{{ GPG key ID }}"
git config [--global] user.email "{{ Email associated with key }}"
```
Create a signed tag at the commit you wish to release. This action will prompt
you to enter a tag message, which can just be the release version.
```
git tag -s v2.0.0 ea4c04fde83bd6c48f4d43862c406deb4ea9dba2
```
Push that tag to the CoreOS repo.
```
git push git@github.com:coreos/dex.git v2.0.0
```
Draft releases on GitHub and summarize the changes since the last release. See
previous releases for the expected format.
https://github.com/coreos/dex/releases
## Minor releases - create a branch
If the release is a minor release (2.1.0, 2.2.0, etc.) create a branch for future patch releases.
```bash
git checkout -b v2.1.x tags/v2.1.0
git push git@github.com:coreos/dex.git v2.1.x
```
## Patch releases - cherry pick required commits
If the release is a patch release (2.0.1, 2.0.2, etc.) checkout the desired release branch and cherry pick specific commits. A patch release is only meant for urgent bug or security fixes.
```bash
RELEASE_BRANCH="v2.0.x"
git checkout $RELEASE_BRANCH
git checkout -b "cherry-picked-change"
git cherry-pick (SHA of change)
git push origin "cherry-picked-change"
```
Open a PR onto $RELEASE_BRANCH to get the changes approved.
## Building the Docker image
Build the Docker image and push to Quay.
```bash
# checkout the tag
git checkout tags/v2.1.0
# will prompt for sudo password
make docker-image
sudo docker push quay.io/coreos/dex:v2.1.0
```

View file

@ -0,0 +1,47 @@
# Getting started
## Building the dex binary
Dex requires a Go installation and a GOPATH configured. For setting up a Go workspace, refer to the [official documentation][go-setup]. Clone it down the correct place, and simply type `make` to compile the dex binary.
```
$ go get github.com/coreos/dex
$ cd $GOPATH/src/github.com/coreos/dex
$ make
```
## Configuration
Dex exclusively pulls configuration options from a config file. Use the [example config][example-config] file found in the `examples/` directory to start an instance of dex with an in-memory data store and a set of predefined OAuth2 clients.
```
./bin/dex serve examples/config-dev.yaml
```
The [example config][example-config] file documents many of the configuration options through inline comments. For extra config options, look at that file.
## Running a client
Dex operates like most other OAuth2 providers. Users are redirected from a client app to dex to login. Dex ships with an example client app (also built with the `make` command), for testing and demos.
By default, the example client is configured with the same OAuth2 credentials defined in `examples/config-dev.yaml` to talk to dex. Running the example app will cause it to query dex's [discovery endpoint][oidc-discovery] and determine the OAuth2 endpoints.
```
./bin/example-app
```
Login to dex through the example app using the following steps.
1. Navigate to the example app in your browser at http://localhost:5555/ in your browser.
2. Hit "login" on the example app to be redirected to dex.
3. Choose the "Login with Email" and enter "admin@example.com" and "password"
4. Approve the example app's request.
5. See the resulting token the example app claims from dex.
## Further reading
Check out the Documentation directory for further reading on setting up different storages, interacting with the dex API, intros for OpenID Connect, and logging in through other identity providers such as Google, GitHub, or LDAP.
[go-setup]: https://golang.org/doc/install
[example-config]: ../examples/config-dev.yaml
[oidc-discovery]: https://openid.net/specs/openid-connect-discovery-1_0-17.html#ProviderMetadata

View file

@ -0,0 +1,67 @@
# Authentication through GitHub
## Overview
One of the login options for dex uses the GitHub OAuth2 flow to identify the end user through their GitHub account.
When a client redeems a refresh token through dex, dex will re-query GitHub to update user information in the ID Token. To do this, __dex stores a readonly GitHub access token in its backing datastore.__ Users that reject dex's access through GitHub will also revoke all dex clients which authenticated them through GitHub.
## Configuration
Register a new application with [GitHub][github-oauth2] ensuring the callback URL is `(dex issuer)/callback`. For example if dex is listening at the non-root path `https://auth.example.com/dex` the callback would be `https://auth.example.com/dex/callback`.
The following is an example of a configuration for `examples/config-dev.yaml`:
```yaml
connectors:
- type: github
# Required field for connector id.
id: github
# Required field for connector name.
name: GitHub
config:
# Credentials can be string literals or pulled from the environment.
clientID: $GITHUB_CLIENT_ID
clientSecret: $GITHUB_CLIENT_SECRET
redirectURI: http://127.0.0.1:5556/dex/callback
# Optional organization to pull teams from, communicate through the
# "groups" scope.
#
# NOTE: This is an EXPERIMENTAL config option and will likely change.
org: my-oranization
```
## GitHub Enterprise
Users can use their GitHub Enterprise account to login to dex. The following configuration can be used to enable a GitHub Enterprise connector on dex:
```yaml
connectors:
- type: github
# Required field for connector id.
id: github
# Required field for connector name.
name: GitHub
config:
# Required fields. Dex must be pre-registered with GitHub Enterprise
# to get the following values.
# Credentials can be string literals or pulled from the environment.
clientID: $GITHUB_CLIENT_ID
clientSecret: $GITHUB_CLIENT_SECRET
redirectURI: http://127.0.0.1:5556/dex/callback
# Optional organization to pull teams from, communicate through the
# "groups" scope.
#
# NOTE: This is an EXPERIMENTAL config option and will likely change.
org: my-oranization
# Required ONLY for GitHub Enterprise.
# This is the Hostname of the GitHub Enterprise account listed on the
# management console. Ensure this domain is routable on your network.
hostName: git.example.com
# ONLY for GitHub Enterprise. Optional field.
# Used to support self-signed or untrusted CA root certificates.
rootCA: /etc/dex/ca.crt
```
[github-oauth2]: https://github.com/settings/applications/new

View file

@ -0,0 +1,29 @@
# Authentication through Gitlab
## Overview
GitLab is a web-based Git repository manager with wiki and issue tracking features, using an open source license, developed by GitLab Inc. One of the login options for dex uses the GitLab OAuth2 flow to identify the end user through their GitLab account. You can use this option with [gitlab.com](gitlab.com), GitLab community or enterprise edition.
When a client redeems a refresh token through dex, dex will re-query GitLab to update user information in the ID Token. To do this, __dex stores a readonly GitLab access token in its backing datastore.__ Users that reject dex's access through GitLab will also revoke all dex clients which authenticated them through GitLab.
## Configuration
Register a new application via `User Settings -> Applications` ensuring the callback URL is `(dex issuer)/callback`. For example if dex is listening at the non-root path `https://auth.example.com/dex` the callback would be `https://auth.example.com/dex/callback`.
The following is an example of a configuration for `examples/config-dev.yaml`:
```yaml
connectors:
- type: gitlab
# Required field for connector id.
id: gitlab
# Required field for connector name.
name: GitLab
config:
# optional, default = https://www.gitlab.com
baseURL: https://www.gitlab.com
# Credentials can be string literals or pulled from the environment.
clientID: $GITLAB_APPLICATION_ID
clientSecret: $GITLAB_CLIENT_SECRET
redirectURI: http://127.0.0.1:5556/dex/callback
```

View file

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View file

@ -0,0 +1,6 @@
# Integrations
This document tracks the libraries and tools that are compatible with dex. [Join the community](https://github.com/coreos/dex/), and help us keep the list up-to-date.
## Tools
## Projects with a dex dependency

124
Documentation/kubernetes.md Normal file
View file

@ -0,0 +1,124 @@
# Kubernetes authentication through dex
## Overview
This document covers setting up the [Kubernetes OpenID Connect token authenticator plugin][k8s-oidc] with dex.
Token responses from OpenID Connect providers include a signed JWT called an ID Token. ID Tokens contain names, emails, unique identifiers, and in dex's case, a set of groups that can be used to identify the user. OpenID Connect providers, like dex, publish public keys; the Kubernetes API server understands how to use these to verify ID Tokens.
The authentication flow looks like:
1. OAuth2 client logs a user in through dex.
2. That client uses the returned ID Token as a bearer token when talking to the Kubernetes API.
3. Kubernetes uses dex's public keys to verify the ID Token.
4. A claim designated as the username (and optionally group information) will be associated with that request.
Username and group information can be combined with Kubernetes [authorization plugins][k8s-authz], such as roles based access control (RBAC), to enforce policy.
## Configuring the OpenID Connect plugin
Configuring the API server to use the OpenID Connect [authentication plugin][k8s-oidc] requires:
* Deploying an API server with specific flags.
* Dex is running on HTTPS.
* Custom CA files must be accessible by the API server (likely through volume mounts).
* Dex is accessible to both your browser and the Kubernetes API server.
Use the following flags to point your API server(s) at dex. `dex.example.com` should be replaced by whatever DNS name or IP address dex is running under.
```
--oidc-issuer-url=https://dex.example.com:32000
--oidc-client-id=example-app
--oidc-ca-file=/etc/kubernetes/ssl/openid-ca.pem
--oidc-username-claim=email
--oidc-groups-claim=groups
```
Additional notes:
* The API server configured with OpenID Connect flags doesn't require dex to be available upfront.
* Other authenticators, such as client certs, can still be used.
* Dex doesn't need to be running when you start your API server.
* Kubernetes only trusts ID Tokens issued to a single client.
* As a work around dex allows clients to [trust other clients][trusted-peers] to mint tokens on their behalf.
* If a claim other than "email" is used for username, for example "sub", it will be prefixed by `"(value of --oidc-issuer-url)#"`. This is to namespace user controlled claims which may be used for privilege escalation.
## Deploying dex on Kubernetes
The dex repo contains scripts for running dex on a Kubernetes cluster with authentication through GitHub. The dex service is exposed using a [node port][node-port] on port 32000. This likely requires a custom `/etc/hosts` entry pointed at one of the cluster's workers.
Because dex uses `ThirdPartyResources` to store state, no external database is needed. For more details see the [storage documentation](storage.md#kubernetes-third-party-resources).
There are many different ways to spin up a Kubernetes development cluster, each with different host requirements and support for API server reconfiguration. At this time, this guide does not have copy-pastable examples, but can recommend the following methods for spinning up a cluster:
* [coreos-kubernetes][coreos-kubernetes] repo for vagrant and VirtualBox users.
* [coreos-baremetal][coreos-baremetal] repo for Linux QEMU/KVM users.
To run dex on Kubernetes perform the following steps:
1. Generate TLS assets for dex.
2. Spin up a Kubernetes cluster with the appropriate flags and CA volume mount.
3. Create a secret containing your [GitHub OAuth2 client credentials][github-oauth2].
4. Deploy dex.
The TLS assets can be created using the following command:
```
$ cd examles/k8s
$ ./gencert.sh
```
The created `ssl/ca.pem` must then be mounted into your API server deployment. Once the cluster is up and correctly configured, use kubectl to add the serving certs as secrets.
```
$ kubectl create secret tls dex.example.com.tls --cert=ssl/cert.pem --key=ssl/key.pem
```
Then create a secret for the GitHub OAuth2 client.
```
$ kubectl create secret \
generic github-client \
--from-literal=client-id=$GITHUB_CLIENT_ID \
--from-literal=client-secret=$GITHUB_CLIENT_SECRET
```
Finally, create the dex deployment, configmap, and node port service.
```
$ kubectl create -f dex.yaml
```
__Caveats:__ No health checking is configured because dex does its own TLS termination complicating the setup. This is a known issue and can be tracked [here][dex-healthz].
## Logging into the cluster
The `example-app` can be used to log into the cluster and get an ID Token. To build the app, you can run `make` in the root of the repo and it will build the `example-app` binary in the repo's `bin` directory. To build the `example-app` requires at least a 1.7 version of Go.
```
$ ./bin/example-app --issuer https://dex.example.com:32000 --issuer-root-ca examples/k8s/ssl/ca.pem
```
Please note that the `example-app` will listen at http://127.0.0.1:5555 and can be changed with the `--listen` flag.
Once the example app is running, choose the GitHub option and grant access to dex to view your profile.
The default redirect uri is http://127.0.0.1:5555/callback and can be changed with the `--redirect-uri` flag and should correspond with your configmap.
The printed ID Token can then be used as a bearer token to authenticate against the API server.
```
$ token='(id token)'
$ curl -H "Authorization: Bearer $token" -k https://( API server host ):443/api/v1/nodes
```
[k8s-authz]: http://kubernetes.io/docs/admin/authorization/
[k8s-oidc]: http://kubernetes.io/docs/admin/authentication/#openid-connect-tokens
[trusted-peers]: https://godoc.org/github.com/coreos/dex/storage#Client
[coreos-kubernetes]: https://github.com/coreos/coreos-kubernetes/
[coreos-baremetal]: https://github.com/coreos/coreos-baremetal/
[dex-healthz]: https://github.com/coreos/dex/issues/682
[github-oauth2]: https://github.com/settings/applications/new
[node-port]: http://kubernetes.io/docs/user-guide/services/#type-nodeport
[coreos-kubernetes]: https://github.com/coreos/coreos-kubernetes
[coreos-baremetal]: https://github.com/coreos/coreos-baremetal

View file

@ -0,0 +1,218 @@
# Authentication through LDAP
## Overview
The LDAP connector allows email/password based authentication, backed by a LDAP directory.
The connector executes two primary queries:
1. Finding the user based on the end user's credentials.
2. Searching for groups using the user entry.
## Security considerations
Dex attempts to bind with the backing LDAP server using the end user's _plain text password_. Though some LDAP implementations allow passing hashed passwords, dex doesn't support hashing and instead _strongly recommends that all administrators just use TLS_. This can often be achieved by using port 636 instead of 389, and administrators that choose 389 are actively leaking passwords.
Dex currently allows insecure connections because the project is still verifying that dex works with the wide variety of LDAP implementations. However, dex may remove this transport option, and _users who configure LDAP login using 389 are not covered by any compatibility guarantees with future releases._
## Configuration
User entries are expected to have an email attribute (configurable through `emailAttr`), and a display name attribute (configurable through `nameAttr`). `*Attr` attributes could be set to "DN" in situations where it is needed but not available elsewhere, and if "DN" attribute does not exist in the record.
The following is an example config file that can be used by the LDAP connector to authenticate a user.
```yaml
connectors:
- type: ldap
# Required field for connector id.
id: ldap
# Required field for connector name.
name: LDAP
config:
# Host and optional port of the LDAP server in the form "host:port".
# If the port is not supplied, it will be guessed based on "insecureNoSSL".
# 389 for insecure connections, 636 otherwise.
host: ldap.example.com:636
# Following field is required if the LDAP host is not using TLS (port 389).
# Because this option inherently leaks passwords to anyone on the same network
# as dex, THIS OPTION MAY BE REMOVED WITHOUT WARNING IN A FUTURE RELEASE.
# insecureNoSSL: true
# If a custom certificate isn't provide, this option can be used to turn on
# TLS certificate checks. As noted, it is insecure and shouldn't be used outside
# of explorative phases.
# insecureSkipVerify: true
# Path to a trusted root certificate file. Default: use the host's root CA.
rootCA: /etc/dex/ldap.ca
# A raw certificate file can also be provided inline.
# rootCAData: ( base64 encoded PEM file )
# The DN and password for an application service account. The connector uses
# these credentials to search for users and groups. Not required if the LDAP
# server provides access for anonymous auth.
bindDN: uid=seviceaccount,cn=users,dc=example,dc=com
bindPW: password
# User search maps a username and password entered by a user to a LDAP entry.
userSearch:
# BaseDN to start the search from. It will translate to the query
# "(&(objectClass=person)(uid=<username>))".
baseDN: cn=users,dc=example,dc=com
# Optional filter to apply when searching the directory.
filter: "(objectClass=person)"
# username attribute used for comparing user entries. This will be translated
# and combined with the other filter as "(<attr>=<username>)".
username: uid
# The following three fields are direct mappings of attributes on the user entry.
# String representation of the user.
idAttr: uid
# Required. Attribute to map to Email.
emailAttr: mail
# Maps to display name of users. No default value.
nameAttr: name
# Group search queries for groups given a user entry.
groupSearch:
# BaseDN to start the search from. It will translate to the query
# "(&(objectClass=group)(member=<user uid>))".
baseDN: cn=groups,dc=freeipa,dc=example,dc=com
# Optional filter to apply when searching the directory.
filter: "(objectClass=group)"
# Following two fields are used to match a user to a group. It adds an additional
# requirement to the filter that an attribute in the group must match the user's
# attribute value.
userAttr: uid
groupAttr: member
# Represents group name.
nameAttr: name
```
The LDAP connector first initializes a connection to the LDAP directory using the `bindDN` and `bindPW`. It then tries to search for the given `username` and bind as that user to verify their password.
Searches that return multiple entries are considered ambiguous and will return an error.
## Example: Mapping a schema to a search config
Writing a search configuration often involves mapping an existing LDAP schema to the various options dex provides. To query an existing LDAP schema install the OpenLDAP tool `ldapsearch`. For `rpm` based distros run:
```
sudo dnf install openldap-clients
```
For `apt-get`:
```
sudo apt-get install ldap-utils
```
For smaller user directories it may be practical to dump the entire contents and search by hand.
```
ldapsearch -x -h ldap.example.org -b 'dc=example,dc=org' | less
```
First, find a user entry. User entries declare users who can login to LDAP connector using username and password.
```
dn: uid=jdoe,cn=users,cn=compat,dc=example,dc=org
cn: Jane Doe
objectClass: posixAccount
objectClass: ipaOverrideTarget
objectClass: top
gidNumber: 200015
gecos: Jane Doe
uidNumber: 200015
loginShell: /bin/bash
homeDirectory: /home/jdoe
mail: jane.doe@example.com
uid: janedoe
```
Compose a user search which returns this user.
```yaml
userSearch:
# The directory directly above the user entry.
baseDN: cn=users,cn=compat,dc=example,dc=org
filter: "(objectClass=posixAccount)"
# Expect user to enter "janedoe" when logging in.
username: uid
# Use the full DN as an ID.
idAttr: DN
# When an email address is not available, use another value unique to the user, like uid.
emailAttr: mail
nameAttr: gecos
```
Second, find a group entry.
```
dn: cn=developers,cn=groups,cn=compat,dc=example,dc=org
memberUid: janedoe
memberUid: johndoe
gidNumber: 200115
objectClass: posixGroup
objectClass: ipaOverrideTarget
objectClass: top
cn: developers
```
Group searches must match a user attribute to a group attribute. In this example, the search returns users whose uid is found in the group's list of memberUid attributes.
```yaml
groupSearch:
# The directory directly above the group entry.
baseDN: cn=groups,cn=compat,dc=example,dc=org
filter: "(objectClass=posixGroup)"
# The group search needs to match the "uid" attribute on
# the user with the "memberUid" attribute on the group.
userAttr: uid
groupAttr: memberUid
# Unique name of the group.
nameAttr: cn
```
## Example: Searching a FreeIPA server with groups
The following configuration will allow the LDAP connector to search a FreeIPA directory using an LDAP filter.
```yaml
connectors:
- type: ldap
id: ldap
name: LDAP
config:
# host and port of the LDAP server in form "host:port".
host: freeipa.example.com:636
# freeIPA server's CA
rootCA: ca.crt
userSearch:
# Would translate to the query "(&(objectClass=person)(uid=<username>))".
baseDN: cn=users,dc=freeipa,dc=example,dc=com
filter: "(objectClass=posixAccount)"
username: uid
idAttr: uid
# Required. Attribute to map to Email.
emailAttr: mail
# Entity attribute to map to display name of users.
groupSearch:
# Would translate to the query "(&(objectClass=group)(member=<user uid>))".
baseDN: cn=groups,dc=freeipa,dc=example,dc=com
filter: "(objectClass=group)"
userAttr: uid
groupAttr: member
nameAttr: name
```
If the search finds an entry, it will attempt to use the provided password to bind as that user entry.

View file

Before

Width:  |  Height:  |  Size: 8 KiB

After

Width:  |  Height:  |  Size: 8 KiB

View file

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View file

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View file

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View file

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

View file

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View file

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -0,0 +1,49 @@
# Authentication through an OpenID Connect provider
## Overview
Dex is able to use another OpenID Connect provider as an authentication source. When logging in, dex will redirect to the upstream provider and perform the necessary OAuth2 flows to determine the end users email, username, etc. More details on the OpenID Connect protocol can be found in [_An overview of OpenID Connect_][oidc-doc].
Prominent examples of OpenID Connect providers include Google Accounts, Salesforce, and Azure AD v2 ([not v1][azure-ad-v1]).
## Caveats
Many OpenID Connect providers implement different restrictions on refresh tokens. For example, Google will only issue the first login attempt a refresh token, then not return one after. Because of this, this connector does not refresh the id_token claims when a client of dex redeems a refresh token, which can result in stale user info.
It's generally recommended to avoid using refresh tokens with the `oidc` connector.
Progress on this caveat can be tracked in [issue #863][google-refreshing].
## Configuration
```yaml
connectors:
- type: oidc
id: google
name: Google
config:
# Canonical URL of the provider, also used for configuration discovery.
# This value MUST match the value returned in the provider config discovery.
#
# See: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig
issuer: https://accounts.google.com
# Connector config values starting with a "$" will read from the environment.
clientID: $GOOGLE_CLIENT_ID
clientSecret: $GOOGLE_CLIENT_SECRET
# Dex's issuer URL + "/callback"
redirectURI: http://127.0.0.1:5556/callback
# Some providers require passing client_secret via POST parameters instead
# of basic auth, despite the OAuth2 RFC discouraging it. Many of these
# cases are caught internally, but some may need to uncommented the
# following field.
#
# basicAuthUnsupported: true
```
[oidc-doc]: openid-connect.md
[google-refreshing]: https://github.com/coreos/dex/issues/863
[azure-ad-v1]: https://github.com/coreos/go-oidc/issues/133

View file

@ -0,0 +1,141 @@
# An overview of OpenID Connect
This document attempts to provide a general overview of the [OpenID Connect protocol](https://openid.net/connect/), a flavor of OAuth2 that dex implements. While this document isn't complete, we hope it provides enough information to get users up and running.
For an overview of custom claims, scopes, and client features implemented by dex, see [this document][scopes-claims-clients].
## OAuth2
OAuth2 should be familiar to anyone who's used something similar to a "Login
with Facebook" button. In these cases an application has chosen to let an
outside provider, in this case Facebook, attest to your identity instead of
having you set a username and password with the app itself.
The general flow for server side apps is:
1. A new user visits an application.
1. The application redirects the user to Facebook.
1. The user logs into Facebook, then is asked if it's okay to let the
application view the user's profile, post on their behalf, etc.
1. If the user clicks okay, Facebook redirects the user back to the application
with a code.
1. The application redeems that code with provider for a token that can be used
to access the authorized actions, such as viewing a users profile or posting on
their wall.
In these cases, dex is acting as Facebook (called the "provider" in OpenID
Connect) while clients apps redirect to it for the end user's identity.
## ID Tokens
Unfortunately the access token applications get from OAuth2 providers is
completely opaque to the client and unique to the provider. The token you
receive from Facebook will be completely different from the one you'd get from
Twitter or GitHub.
OpenID Connect's primary extension of OAuth2 is an additional token returned in
the token response called the ID Token. This token is a [JSON Web Token](
https://tools.ietf.org/html/rfc7519) signed by the OpenID Connect server, with
well known fields for user ID, name, email, etc. A typical token response from
an OpenID Connect looks like (with less whitespace):
```
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
Pragma: no-cache
{
"access_token": "SlAV32hkKG",
"token_type": "Bearer",
"refresh_token": "8xLOxBtZp8",
"expires_in": 3600,
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjFlOWdkazcifQ.ewogImlzc
yI6ICJodHRwOi8vc2VydmVyLmV4YW1wbGUuY29tIiwKICJzdWIiOiAiMjQ4Mjg5
NzYxMDAxIiwKICJhdWQiOiAiczZCaGRSa3F0MyIsCiAibm9uY2UiOiAibi0wUzZ
fV3pBMk1qIiwKICJleHAiOiAxMzExMjgxOTcwLAogImlhdCI6IDEzMTEyODA5Nz
AKfQ.ggW8hZ1EuVLuxNuuIJKX_V8a_OMXzR0EHR9R6jgdqrOOF4daGU96Sr_P6q
Jp6IcmD3HP99Obi1PRs-cwh3LO-p146waJ8IhehcwL7F09JdijmBqkvPeB2T9CJ
NqeGpe-gccMg4vfKjkM8FcGvnzZUN4_KSP0aAp1tOJ1zZwgjxqGByKHiOtX7Tpd
QyHE5lcMiKPXfEIQILVq0pc_E2DzL7emopWoaoZTF_m0_N0YzFC6g6EJbOEoRoS
K5hoDalrcvRYLSrQAZZKflyuVCyixEoV9GfNQC3_osjzw2PAithfubEEBLuVVk4
XUVrWOLrLl0nx7RkKU8NXNHq-rvKMzqg"
}
```
That ID Token is a JWT with three base64'd fields separated by dots. The first
is a header, the second is a payload, and the third is a signature of the first
two fields. When parsed we can see the payload of this value is.
```
{
"iss": "http://server.example.com",
"sub": "248289761001",
"aud": "s6BhdRkqt3",
"nonce": "n-0S6_WzA2Mj",
"exp": 1311281970,
"iat": 1311280970
}
```
This has a few interesting fields such as
* The server that issued this token (`iss`).
* The token's subject (`sub`). In this case a unique ID of the end user.
* The token's audience (`aud`). The ID of the OAuth2 client this was issued for.
TODO: Add examples of payloads with "email" fields.
## Discovery
OpenID Connect servers have a discovery mechanism for OAuth2 endpoints, scopes
supported, and indications of various other OpenID Connect features.
```
$ curl http://127.0.0.1:5556/.well-known/openid-configuration
{
"issuer": "http://127.0.0.1:5556",
"authorization_endpoint": "http://127.0.0.1:5556/auth",
"token_endpoint": "http://127.0.0.1:5556/token",
"jwks_uri": "http://127.0.0.1:5556/keys",
"response_types_supported": [
"code"
],
"subject_types_supported": [
"public"
],
"id_token_signing_alg_values_supported": [
"RS256"
],
"scopes_supported": [
"openid",
"email",
"profile"
]
}
```
Importantly, we've discovered the authorization endpoint, token endpoint, and
the location of the server's public keys. OAuth2 clients should be able to use
the token and auth endpoints immediately, while a JOSE library can be used to
parse the keys. The keys endpoint returns a [JSON Web Key](
https://tools.ietf.org/html/rfc7517) Set of public keys that will look
something like this:
```
$ curl http://127.0.0.1:5556/keys
{
"keys": [
{
"use": "sig",
"kty": "RSA",
"kid": "5d19a0fde5547960f4edaa1e1e8293e5534169ba",
"alg": "RS256",
"n": "5TAXCxkAQqHEqO0InP81z5F59PUzCe5ZNaDsD1SXzFe54BtXKn_V2a3K-BUNVliqMKhC2LByWLuI-A5ZlA5kXkbRFT05G0rusiM0rbkN2uvRmRCia4QlywE02xJKzeZV3KH6PldYqV_Jd06q1NV3WNqtcHN6MhnwRBfvkEIm7qWdPZ_mVK7vayfEnOCFRa7EZqr-U_X84T0-50wWkHTa0AfnyVvSMK1eKL-4yc26OWkmjh5ALfQFtnsz30Y2TOJdXtEfn35Y_882dNBDYBxtJV4PaSjXCxhiaIuBHp5uRS1INyMXCx2ve22ASNx_ERorv6BlXQoMDqaML2bSiN9N8Q",
"e": "AQAB"
}
]
}
```
[scopes-claims-clients]: custom-scopes-claims-clients.md

View file

@ -0,0 +1,3 @@
# Production users
This document tracks people and use cases for dex in production. [Join the community](https://github.com/coreos/dex/), and help us keep the list up-to-date.

View file

@ -0,0 +1,82 @@
# Proposal: design for revoking refresh tokens.
Refresh tokens are issued to the client by the authorization server and are used
to request a new access token when the current access token becomes invalid or expires.
It is a common usecase for the end users to revoke client access to their identity.
This proposal defines the changes needed in Dex v2 to support refresh token revocation.
## Motivation
1. Currently refresh tokens are not associated with the user. Need a new "session object" for this.
2. Need an API to list refresh tokens based on the UserID.
3. We need a way for users to login to dex and revoke a client.
4. Limit the number refresh tokens for each user-client pair to 1.
## Details
Currently in Dex when an end user successfully logs in via a connector and has the OfflineAccess
scope set to true, a refresh token is created and stored in the backing datastore. There is no
association between the end user and the refresh token. Hence if we want to support the functionality
of users being able to revoke refresh tokens, the first step is to have a structure in place that allows
us retrieve a list of refresh tokens depending on the authenticated user.
```go
// Reference object for RefreshToken containing only metadata.
type RefreshTokenRef struct {
// ID of the RefreshToken
ID string
CreatedAt time.Time
LastUsed time.Time
}
// Session objects pertaining to users with refresh tokens.
//
// Will have to handle garbage collection i.e. if no refresh token exists for a user,
// this object must be cleaned up.
type OfflineSession struct {
// UserID of an end user who has logged in to the server.
UserID string
// The ID of the connector used to login the user.
ConnID string
// List of pointers to RefreshTokens issued for SessionID
Refresh []*RefreshTokenRef
}
// Retrieve OfflineSession obj for given userId and connID
func getOfflineSession (userId string, connID string)
```
### Changes in Dex CodeFlows
1. Client requests a refresh token:
Try to retrieve the `OfflineSession` object for the User with the given `UserID + ConnID`.
This leads to two possibilities:
* Object exists: This means a Refresh token already exists for the user.
Update the existing `OffilineSession` object with the newly received token as follows:
* CreateRefresh() will create a new `RefreshToken` obj in the storage.
* Update the `Refresh` list with the new `RefreshToken` pointer.
* Delete the old refresh token in storage.
* No object found: This implies that this will be the first refresh token for the user.
* CreateRefresh() will create a new `RefreshToken` obj in the storage.
* Create an OfflineSession for the user and add the new `RefreshToken` pointer to
the `Refresh` list.
2. Refresh token rotation:
There will be no change to this codeflow. When the client refreshes a refresh token, the `TokenID`
still remains intact and only the `RefreshToken` obj gets updated with a new nonce. We do not need
any additional checks in the OfflineSession objects as the `RefreshToken` pointers still remain intact.
3. User revokes a refresh token (New functionality):
A user that has been authenticated externally will have the ability to revoke their refresh tokens.
Please note that Dex's API does not perform the authentication, this will have to be done by an
external app.
Steps involved:
* Get `OfflineSession` obj with given UserID + ConnID.
* If a refresh token exists in `Refresh`, delete the `RefreshToken` (handle this in storage)
and its pointer value in `Refresh`. Clean up the OfflineSession object.
* If there is no refresh token found, handle error case.
NOTE: To avoid race conditions between “requesting a refresh token” and “revoking a refresh token”, use
locking mechanism when updating an `OfflineSession` object.

View file

@ -0,0 +1,165 @@
# Proposal: upstream refreshing
## TL;DR
Today, if a user deletes their GitHub account, dex will keep allowing clients to
refresh tokens on that user's behalf because dex never checks back in with
GitHub.
This is a proposal to change the connector package so the dex can check back
in with GitHub.
## The problem
When dex is federaing to an upstream identity provider (IDP), we want to ensure
claims being passed onto clients remain fresh. This includes data such as Google
accounts display names, LDAP group membership, account deactivations. Changes to
these on an upstream IDP should always be reflected in the claims dex passes to
its own clients.
Refresh tokens make this complicated. When refreshing a token, unlike normal
logins, dex doesn't have the opportunity to prompt for user interaction. For
example, if dex is proxying to a LDAP server, it won't have the user's username
and passwords.
Dex can't do this today because connectors have no concept of checking back in
with an upstream provider (with the sole exception of groups). They're only
called during the initial login, and never consulted when dex needs to mint a
new refresh token for a client. Additionally, connectors aren't actually aware
of the scopes being requested by the client, so they don't know when they should
setup the ability to check back in and have to treat every request identically.
## Changes to the connector package
The biggest changes proposed impact the connector package and connector
implementations.
1. Connectors should be consulted when dex attempts to refresh a token.
2. Connectors should be aware of the scopes requested by the client.
The second bullet is important because of the first. If a client isn't
requesting a refresh token, the connector shouldn't do the extra work, such as
requesting additional upstream scopes.
to address the first point, a top level `Scopes` object will be added to the
connector package to express the scopes requested by the client. The
`CallbackConnector` and `PasswordConnector` will be updated accordingly.
```go
// Scopes represents additional data requested by the clients about the end user.
type Scopes struct{
// The client has requested a refresh token from the server.
OfflineAccess bool
// The client has requested group information about the end user.
Groups bool
}
// CallbackConnector is an interface implemented by connectors which use an OAuth
// style redirect flow to determine user information.
type CallbackConnector interface {
// The initial URL to redirect the user to.
//
// OAuth2 implementations should request different scopes from the upstream
// identity provider based on the scopes requested by the downstream client.
// For example, if the downstream client requests a refresh token from the
// server, the connector should also request a token from the provider.
LoginURL(s Scopes, callbackURL, state string) (string, error)
// Handle the callback to the server and return an identity.
HandleCallback(s Scopes, r *http.Request) (identity Identity, state string, err error)
}
// PasswordConnector is an interface implemented by connectors which take a
// username and password.
type PasswordConnector interface {
Login(s Scopes, username, password string) (identity Identity, validPassword bool, err error)
}
```
The existing `GroupsConnector` plays two roles.
1. The connector only attempts to grab groups when the downstream client requests it.
2. Allow group information to be refreshed.
The first issue is remedied by the added `Scopes` struct. This proposal also
hopes to generalize the need of the second role by adding a more general
`RefreshConnector`:
```go
type Identity struct {
// Existing fields...
// Groups are added to the identity object, since connectors are now told
// if they're being requested.
// The set of groups a user is a member of.
Groups []string
}
// RefreshConnector is a connector that can update the client claims.
type RefreshConnector interface {
// Refresh is called when a client attempts to claim a refresh token. The
// connector should attempt to update the identity object to reflect any
// changes since the token was last refreshed.
Refresh(s Scopes, identity Identity) (Identity, error)
// TODO(ericchiang): Should we allow connectors to indicate that the user has
// been delete or an upstream token has been revoked? This would allow us to
// know when we should remove the downstream refresh token, and when there was
// just a server error, but might be hard to determine for certain protocols.
// Might be safer to always delete the downstream token if the Refresh()
// method returns an error.
}
```
## Example changes to the "passwordDB" connector
The `passwordDB` connector is the internal connector maintained by the server.
As an example, these are the changes to that connector if this change was
accepted.
```go
func (db passwordDB) Login(s connector.Scopes, username, password string) (connector.Identity, bool, error) {
// No change to existing implementation. Scopes can be ignored since we'll
// always have access to the password objects.
}
func (db passwordDB) Refresh(s connector.Scopes, identity connector.Identity) (connector.Identity, error) {
// If the user has been deleted, the refresh token will be rejected.
p, err := db.s.GetPassword(identity.Email)
if err != nil {
if err == storage.ErrNotFound {
return connector.Identity{}, errors.New("user not found")
}
return connector.Identity{}, fmt.Errorf("get password: %v", err)
}
// User removed but a new user with the same email exists.
if p.UserID != identity.UserID {
return connector.Identity{}, errors.New("user not found")
}
// If a user has updated their username, that will be reflected in the
// refreshed token.
identity.Username = p.Username
return identity, nil
}
```
## Caveats
Certain providers, such as Google, will only grant a single refresh token for each
client + end user pair. The second time one's requested, no refresh token is
returned. This means refresh tokens must be stored by dex as objects on an
upstream identity rather than part of a downstream refresh even.
Right now `ConnectorData` is too general for this since it is only stored with a
refresh token and can't be shared between sessions. This should be rethought in
combination with the [`user-object.md`](./user-object.md) proposal to see if
there are reasonable ways for us to do this.
This isn't a problem for providers like GitHub because they return the same
refresh token every time. We don't need to track a token per client.

View file

@ -0,0 +1,146 @@
# Proposal: user objects for revoking refresh tokens and merging accounts
Certain operations require tracking users the have logged in through the server
and storing them in the backend. Namely, allowing end users to revoke refresh
tokens and merging existing accounts with upstream providers.
While revoking refresh tokens is relatively easy, merging accounts is a
difficult problem. What if display names or emails are different? What happens
to a user with two remote identities with the same upstream service? Should
this be presented differently for a user with remote identities for different
upstream services? This proposal only covers a minimal merging implementation
by guaranteeing that merged accounts will always be presented to clients with
the same user ID.
This proposal defines the following objects and methods to be added to the
storage package to allow user information to be persisted.
```go
// User is an end user which has logged in to the server.
//
// Users do not hold additional data, such as emails, because claim information
// is always supplied by an upstream provider during the auth flow. The ID is
// the only information from this object which overrides the claims produced by
// connectors.
//
// Clients which wish to associate additional data with a user must do so on
// their own. The server only guarantees that IDs will be constant for an end
// user, no matter what backend they use to login.
type User struct {
// A string which uniquely identifies the user for the server. This overrides
// the ID provided by the connector in the ID Token claims.
ID string
// A list of clients who have been issued refresh tokens for this user.
//
// When a refresh token is redeemed, the server will check this field to
// ensure that the client is still on this list. To revoke a client,
// remove it from here.
AuthorizedClients []AuthorizedClient
// A set of remote identities which are able to login as this user.
RemoteIdentities []RemoteIdentity
}
// AuthorizedClient is a client that has a refresh token out for this user.
type AuthorizedClient struct {
// The ID of the client.
ClientID string
// The last time a token was refreshed.
LastRefreshed time.Time
}
// RemoteIdentity is the smallest amount of information that identifies a user
// with a remote service. It indicates which remote identities should be able
// to login as a specific user.
//
// RemoteIdentity contains an username so an end user can be displayed this
// object and reason about what upstream profile it represents. It is not used
// to cache claims, such as groups or emails, because these are always provided
// by the upstream identity system during login.
type RemoteIdentity struct {
// The ID of the connector used to login the user.
ConnectorID string
// A string which uniquely identifies the user with the remote system.
ConnectorUserID stirng
// Optional, human readable name for this remote identity. Only used when
// displaying the remote identity to the end user (e.g. when merging
// accounts). NOT used for determining ID Token claims.
Username string
}
```
`UserID` fields will be added to the `AuthRequest`, `AuthCode` and `RefreshToken`
structs. When a user logs in successfully through a connector
[here](https://github.com/coreos/dex/blob/95a61454b522edd6643ced36b9d4b9baa8059556/server/handlers.go#L227),
the server will attempt to either get the user, or create one if none exists with
the remote identity.
`AuthorizedClients` serves two roles. First is makes displaying the set of
clients a user is logged into easy. Second, because we don't assume multi-object
transactions, we can't ensure deleting all refresh tokens a client has for a
user. Between listing the set of refresh tokens and deleting a token, a client
may have already redeemed the token and created a new one.
When an OAuth2 client exchanges a code for a token, the following steps are
taken to populate the `AuthorizedClients`:
1. Get token where the user has authorized the `offline_access` scope.
1. Update the user checking authorized clients. If client is not in the list,
add it.
1. Create a refresh token and return the token.
When a OAuth2 client attempts to renew a refresh token, the server ensures that
the token hasn't been revoked.
1. Check authorized clients and update the `LastRefreshed` timestamp. If client
isn't in list error out and delete the refresh token.
1. Continue renewing the refresh token.
When the end user revokes a client, the following steps are used to.
1. Update the authorized clients by removing the client from the list. This
atomic action causes any renew attempts to fail.
1. Iterate through list of refresh tokens and garbage collect any tokens issued
by the user for the client. This isn't atomic, but exists so a user can
re-authorize a client at a later time without authorizing old refresh tokens.
This is clunky due to the lack of multi-object transactions. E.g. we can't delete
all the refresh tokens at once because we don't have that guarantee.
Merging accounts becomes extremely simple. Just add another remote identity to
the user object.
We hope to provide a web interface that a user can login to to perform these
actions. Perhaps using a well known client issued exclusively for the server.
The new `User` object requires adding the following methods to the storage
interface, and (as a nice side effect) deleting the `ListRefreshTokens()` method.
```go
type Storage interface {
// ...
CreateUser(u User) error
DeleteUser(id string) error
GetUser(id string) error
GetUserByRemoteIdentity(connectorID, connectorUserID string) (User, error)
// Updates are assumed to be atomic.
//
// When a UpdateUser is called, if clients are removed from the
// AuthorizedClients list, the underlying storage SHOULD clean up refresh
// tokens issued for the removed clients. This allows backends with
// multi-transactional capabilities to utilize them, while key-value stores
// only guarantee best effort.
UpdateUser(id string, updater func(old User) (User, error)) error
}
```
Importantly, this will be the first object which has a secondary index.
The Kubernetes client will simply list all the users in memory then iterate over
them to support this (possibly followed by a "watch" based optimization). SQL
implementations will have an easier time.

View file

@ -0,0 +1,105 @@
# Authentication through SAML 2.0
## Overview
The SAML provider allows authentication through the SAML 2.0 HTTP POST binding. The connector maps attribute values in the SAML assertion to user info, such as username, email, and groups.
The connector uses the value of the `NameID` element as the user's unique identifier which dex assumes is both unique and never changes. Use the `nameIDPolicyFormat` to ensure this is set to a value which satisfies these requirements.
Unlike some clients which will process unprompted AuthnResponses, dex must send the initial AuthnRequest and validates the response's InResponseTo value.
## Caveats
__The connector doesn't support refresh tokens__ since the SAML 2.0 protocol doesn't provide a way to requery a provider without interaction. If the "offline_access" scope is requested, it will be ignored.
The connector doesn't support signed AuthnRequests or encrypted attributes.
## Configuration
```yaml
connectors:
- type: saml
# Required field for connector id.
id: saml
# Required field for connector name.
name: SAML
config:
# SSO URL used for POST value.
ssoURL: https://saml.example.com/sso
# CA to use when validating the signature of the SAML response.
ca: /path/to/ca.pem
# Dex's callback URL.
#
# If the response assertion status value contains a Destination element, it
# must match this value exactly.
#
# This is also used as the expected audience for AudienceRestriction elements
# if entityIssuer isn't specified.
redirectURI: https://dex.example.com/callback
# Name of attributes in the returned assertions to map to ID token claims.
usernameAttr: name
emailAttr: email
groupsAttr: groups # optional
# CA's can also be provided inline as a base64'd blob.
#
# caData: ( RAW base64'd PEM encoded CA )
# To skip signature validation, uncomment the following field. This should
# only be used during testing and may be removed in the future.
#
# insecureSkipSignatureValidation: true
# Optional: Manually specify dex's Issuer value.
#
# When provided dex will include this as the Issuer value during AuthnRequest.
# It will also override the redirectURI as the required audience when evaluating
# AudienceRestriction elements in the response.
entityIssuer: https://dex.example.com/callback
# Optional: Issuer value expected in the SAML response.
ssoIssuer: https://saml.example.com/sso
# Optional: Delimiter for splitting groups returned as a single string.
#
# By default, multiple groups are assumed to be represented as multiple
# attributes with the same name.
#
# If "groupsDelim" is provided groups are assumed to be represented as a
# single attribute and the delimiter is used to split the attribute's value
# into multiple groups.
groupsDelim: ", "
# Optional: Requested format of the NameID.
#
# The NameID value is is mapped to the user ID of the user. This can be an
# abbreviated form of the full URI with just the last component. For example,
# if this value is set to "emailAddress" the format will resolve to:
#
# urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
#
# If no value is specified, this value defaults to:
#
# urn:oasis:names:tc:SAML:2.0:nameid-format:persistent
#
nameIDPolicyFormat: persistent
```
A minimal working configuration might look like:
```yaml
connectors:
- type: saml
id: okta
name: Okta
config:
ssoURL: https://dev-111102.oktapreview.com/app/foo/exk91cb99lKkKSYoy0h7/sso/saml
ca: /etc/dex/saml-ca.pem
redirectURI: http://127.0.0.1:5556/dex/callback
usernameAttr: name
emailAttr: email
groupsAttr: groups
```

168
Documentation/storage.md Normal file
View file

@ -0,0 +1,168 @@
# Storage options
Dex requires persisting state to perform various tasks such as track refresh tokens, preventing replays, and rotating keys. This document is a summary of the storage configurations supported by dex.
Storage breaches are serious as they can affect applications that rely on dex. Dex saves sensitive data in its backing storage, including signing keys and bcrypt'd passwords. As such, transport security and database ACLs should both be used, no matter which storage option is chosen.
## Kubernetes third party resources
__NOTE:__ Dex requires Kubernetes version 1.4+.
Kubernetes third party resources are a way for applications to create new resources types in the Kubernetes API. This allows dex to run on top of an existing Kubernetes cluster without the need for an external database. While this storage may not be appropriate for a large number of users, it's extremely effective for many Kubernetes use cases.
The rest of this section will explore internal details of how dex uses `ThirdPartyResources`. __Admins should not interact with these resources directly__, except when debugging. These resources are only designed to store state and aren't meant to be consumed by humans. For modifying dex's state dynamically see the [API documentation](api.md).
The `ThirdPartyResource` type acts as a description for the new resource a user wishes to create. The following an example of a resource managed by dex:
```
kind: ThirdPartyResource
apiVersion: extensions/v1beta1
metadata:
name: o-auth2-client.oidc.coreos.com
versions:
- name: v1
description: "An OAuth2 client."
```
Once the `ThirdPartyResource` is created, custom resources can be created at a namespace level (though there will be a gap between the `ThirdPartyResource` being created and the API server accepting the custom resource). While most fields are user defined, the API server still respects the common `ObjectMeta` and `TypeMeta` values. For example names are still restricted to a small set of characters, and the `resourceVersion` field can be used for an [atomic compare and swap][k8s-api].
The following is an example of a custom `OAuth2Client` resource:
```
# Standard Kubernetes resource fields
kind: OAuth2Client
apiVersion: oidc.coreos.com/v1
metadata:
namespace: foobar
name: ( opaque hash )
# Custom fields defined by dex.
clientID: "aclientid"
clientSecret: "clientsecret"
redirectURIs:
- "https://app.example.com/callback"
```
The `ThirdPartyResource` type and the custom resources can be queried, deleted, and edited like any other resource using `kubectl`.
```
kubectl get thirdpartyresources # list third party resources registered on the clusters
kubectl get --namespace=foobar oauth2clients # list oauth2 clients in a given namespace
```
To reduce administrative overhead, dex creates and manages its own third party resources and may create new ones during upgrades. While not strictly required we feel this is important for reasonable updates. Though, as a result, dex requires access to the non-namespaced `ThirdPartyResource` type. For example, clusters using RBAC authorization would need to create the following roles and bindings:
```
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1alpha1
metadata:
name: dex
rules:
- apiGroups: ["oidc.coreos.com"] # API group created by dex
resources: ["*"]
verbs: ["*"]
nonResourceURLs: []
- apiGroups: ["extensions"]
resources: ["thirdpartyresources"]
verbs: ["create"] # To manage its own resources identity must be able to create thirdpartyresources.
nonResourceURLs: []
---
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1alpha1
metadata:
name: dex
subjects:
- kind: ServiceAccount
name: dex # Service account assigned to the dex pod.
namespace: demo-namespace # The namespace dex is running in.
roleRef:
kind: ClusterRole
name: identity
apiVersion: rbac.authorization.k8s.io/v1alpha1
```
The storage configuration is extremely limited since installations running outside a Kubernetes cluster would likely prefer a different storage option. An example configuration for dex running inside Kubernetes:
```
storage:
type: kubernetes
config:
inCluster: true
```
Dex determines the namespace it's running in by parsing the service account token automatically mounted into its pod.
## SQL
Dex supports two flavors of SQL, SQLite3 and Postgres. MySQL and CockroachDB may be added at a later time.
Migrations are performed automatically on the first connection to the SQL server (it does not support rolling back). Because of this dex requires privileges to add and alter the tables for its database.
__NOTE:__ Previous versions of dex required symmetric keys to encrypt certain values before sending them to the database. This feature has not yet been ported to dex v2. If it is added later there may not be a migration path for current v2 users.
### SQLite3
SQLite3 is the recommended storage for users who want to stand up dex quickly. It is __not__ appropriate for real workloads.
The SQLite3 configuration takes a single argument, the database file.
```
storage:
type: sqlite3
config:
file: /var/dex/dex.db
```
Because SQLite3 uses file locks to prevent race conditions, if the ":memory:" value is provided dex will automatically disable support for concurrent database queries.
### Postgres
When using Postgres, admins may want to dedicate a database to dex for the following reasons:
1. Dex requires privileged access to its database because it performs migrations.
2. Dex's database table names are not configurable; when shared with other applications there may be table name clashes.
```
CREATE DATABASE dex_db;
CREATE USER dex WITH PASSWORD '66964843358242dbaaa7778d8477c288';
GRANT ALL PRIVILEGES ON DATABASE dex_db TO dex;
```
An example config for Postgres setup using these values:
```
storage:
type: postgres
config:
database: dex_db
user: dex
password: 66964843358242dbaaa7778d8477c288
ssl:
mode: verify-ca
caFile: /etc/dex/postgres.ca
```
The SSL "mode" corresponds to the `github.com/lib/pq` package [connection options][psql-conn-options]. If unspecified, dex defaults to the strictest mode "verify-full".
## Adding a new storage options
Each storage implementation bears a large ongoing maintenance cost and needs to be updated every time a feature requires storing a new type. Bugs often require in depth knowledge of the backing software, and much of this work will be done by developers who are not the original author. Changes to dex which add new storage implementations are not merged lightly.
### New storage option references
Those who still want to construct a proposal for a new storage should review the following packages:
* `github.com/coreos/dex/storage`: Interface definitions which the storage must implement. __NOTE:__ This package is not stable.
* `github.com/coreos/dex/storage/conformance`: Conformance tests which storage implementations must pass.
### New storage option requirements
Any proposal to add a new implementation must address the following:
* Integration testing setups (Travis and developer workstations).
* Transactional requirements: atomic deletes, updates, etc.
* Is there an established and reasonable Go client?
[issues-transaction-tests]: https://github.com/coreos/dex/issues/600
[k8s-api]: https://github.com/kubernetes/kubernetes/blob/master/docs/devel/api-conventions.md#concurrency-control-and-consistency
[psql-conn-options]: https://godoc.org/github.com/lib/pq#hdr-Connection_String_Parameters

47
Documentation/v2.md Normal file
View file

@ -0,0 +1,47 @@
# Dex v2
## Streamlined deployments
Many of the changes between v1 and v2 were aimed at making dex easier to deploy and manage, perhaps the biggest pain point for dex v1. Dex is now a single, scalable binary with a sole source of configuration. Many components which previously had to be set through the API, such as OAuth2 clients and IDP connectors can now be specified statically. The new architecture lacks a singleton component eliminating deployment ordering. There are no more special development modes; instructions for running dex on a workstation translate with minimal changes to a production system.
All of this results in a much simpler deployment story. Write a config file, run the dex binary, and that's it.
## More storage backends
Dex's internal storage interface has been improved to support multiple backing databases including Postgres, SQLite3, and the Kubernetes API through Third Party Resources. This allows dex to meet a more diverse set of use cases instead of insisting on one particular deployment pattern. For example, The Kubernetes API implementation, a [key value store][k8s-api-docs], allows dex to be run natively on top of a Kubernetes cluster with extremely little administrative overhead. Starting with support for multiple storage backends also should help ensure that the dex storage interface is actually pluggable, rather than being coupled too tightly with a single implementation.
A more in depth discussion of existing storage options and how to add new ones can be found [here][storage-docs].
## Additional improvements
The rewrite came with several, miscellaneous improvements including:
* More powerful connectors. For example the GitHub connector can now query for teams.
* Combined the two APIs into a single [gRPC API][api-docs] with no complex authorization rules.
* Expanded OAuth2 capabilities such as the implicit flow.
* Simplified codebase and improved testing.
## Rethinking registration
Dex v1 performed well when it could manage users. It provided features such as registration, email invites, password resets, administrative abilities, etc. However, login flows and APIs remain tightly coupled with concepts like registration and admin users even when v1 federated to an upstream identity provider (IDP) where it likely only had read only access to the actual user database.
Many of v2's use cases focus on federation to other IPDs rather than managing users itself. Because of this, options associated with registration, such as SMTP credentials, have been removed. We hope to add registration and user management back into the project through orthogonal applications using the [gRPC API][api-docs], but in a way that doesn't impact other use cases.
## Removed features
Dex v2 lacks certain features present in v1. For the most part _we aim to add most of these features back into v2_, but in a way that installations have to _opt in_ to a feature instead of burdening every deployment with extra configuration.
Notable missing features include:
* Registration flows.
* Local user management.
* SMTP configuration and email verification.
* Several of the login connectors that have yet to be ported.
## Support for dex v1
Dex v1 will continue to live under the `github.com/coreos/dex` repo on a branch. Bug fixes and minor changes will continue to be accepted, but development of new features by the dex team will largely cease.
[k8s-api-docs]: http://kubernetes.io/docs/api/
[storage-docs]: ./storage.md
[api-docs]: ./api.md

View file

@ -1,6 +0,0 @@
Joel Speed <Joel.speed@hotmail.co.uk> (@JoelSpeed)
Maksim Nabokikh <max.nabokih@gmail.com> (@nabokihms)
Mark Sagi-Kazar <mark.sagikazar@gmail.com> (@sagikazarmark)
Nandor Kracser <bonifaido@gmail.com> (@bonifaido)
Rithu John <rithujohn191@gmail.com> (@rithujohn191)
Stephen Augustus <foo@auggie.dev> (@justaugustus)

183
Makefile
View file

@ -1,162 +1,103 @@
OS = $(shell uname | tr A-Z a-z)
export PATH := $(abspath bin/protoc/bin/):$(abspath bin/):${PATH}
PROJ=dex PROJ=dex
ORG_PATH=github.com/dexidp ORG_PATH=github.com/coreos
REPO_PATH=$(ORG_PATH)/$(PROJ) REPO_PATH=$(ORG_PATH)/$(PROJ)
export PATH := $(PWD)/bin:$(PATH)
VERSION ?= $(shell ./scripts/git-version) VERSION ?= $(shell ./scripts/git-version)
DOCKER_REPO=quay.io/dexidp/dex DOCKER_REPO=quay.io/coreos/dex
DOCKER_IMAGE=$(DOCKER_REPO):$(VERSION) DOCKER_IMAGE=$(DOCKER_REPO):$(VERSION)
$( shell mkdir -p bin ) $( shell mkdir -p bin )
$( shell mkdir -p _output/images )
$( shell mkdir -p _output/bin )
user=$(shell id -u -n) user=$(shell id -u -n)
group=$(shell id -g -n) group=$(shell id -g -n)
export GOBIN=$(PWD)/bin export GOBIN=$(PWD)/bin
# Prefer ./bin instead of system packages for things like protoc, where we want
# to use the version dex uses, not whatever a developer has installed.
export PATH=$(GOBIN):$(shell printenv PATH)
export GO15VENDOREXPERIMENT=1
LD_FLAGS="-w -X main.version=$(VERSION)" LD_FLAGS="-w -X $(REPO_PATH)/version.Version=$(VERSION)"
# Dependency versions build: bin/dex bin/example-app bin/grpc-client
KIND_NODE_IMAGE = "kindest/node:v1.19.11@sha256:07db187ae84b4b7de440a73886f008cf903fcf5764ba8106a9fd5243d6f32729" bin/dex: check-go-version
KIND_TMP_DIR = "$(PWD)/bin/test/dex-kind-kubeconfig"
.PHONY: generate
generate:
@go generate $(REPO_PATH)/storage/ent/
build: generate bin/dex
bin/dex:
@mkdir -p bin/
@go install -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/dex @go install -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/dex
examples: bin/grpc-client bin/example-app bin/example-app: check-go-version
@go install -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/example-app
bin/grpc-client: bin/grpc-client: check-go-version
@mkdir -p bin/ @go install -v -ldflags $(LD_FLAGS) $(REPO_PATH)/examples/grpc-client
@cd examples/ && go install -v -ldflags $(LD_FLAGS) $(REPO_PATH)/examples/grpc-client
bin/example-app:
@mkdir -p bin/
@cd examples/ && go install -v -ldflags $(LD_FLAGS) $(REPO_PATH)/examples/example-app
.PHONY: release-binary .PHONY: release-binary
release-binary: LD_FLAGS = "-w -X main.version=$(VERSION) -extldflags \"-static\"" release-binary:
release-binary: generate
@go build -o /go/bin/dex -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/dex @go build -o /go/bin/dex -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/dex
@go build -o /go/bin/docker-entrypoint -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/docker-entrypoint
docker-compose.override.yaml: .PHONY: revendor
cp docker-compose.override.yaml.dist docker-compose.override.yaml revendor:
@glide up -v
.PHONY: up @glide-vc --use-lock-file --no-tests --only-code
up: docker-compose.override.yaml ## Launch the development environment
@ if [ docker-compose.override.yaml -ot docker-compose.override.yaml.dist ]; then diff -u docker-compose.override.yaml docker-compose.override.yaml.dist || (echo "!!! The distributed docker-compose.override.yaml example changed. Please update your file accordingly (or at least touch it). !!!" && false); fi
docker-compose up -d
.PHONY: down
down: clear ## Destroy the development environment
docker-compose down --volumes --remove-orphans --rmi local
test: test:
@go test -v ./... @go test -v -i $(shell go list ./... | grep -v '/vendor/')
@go test -v $(shell go list ./... | grep -v '/vendor/')
testrace: testrace:
@go test -v --race ./... @go test -v -i --race $(shell go list ./... | grep -v '/vendor/')
@go test -v --race $(shell go list ./... | grep -v '/vendor/')
.PHONY: kind-up kind-down kind-tests vet:
kind-up: @go vet $(shell go list ./... | grep -v '/vendor/')
@mkdir -p bin/test
@kind create cluster --image ${KIND_NODE_IMAGE} --kubeconfig ${KIND_TMP_DIR}
kind-down: fmt:
@kind delete cluster @go fmt $(shell go list ./... | grep -v '/vendor/')
rm ${KIND_TMP_DIR}
kind-tests: export DEX_KUBERNETES_CONFIG_PATH=${KIND_TMP_DIR} lint:
kind-tests: testall @for package in $(shell go list ./... | grep -v '/vendor/' | grep -v '/api' | grep -v '/server/internal'); do \
golint -set_exit_status $$package $$i || exit 1; \
done
.PHONY: lint lint-fix _output/bin/dex:
lint: ## Run linter @./scripts/docker-build
golangci-lint run @sudo chown $(user):$(group) _output/bin/dex
.PHONY: fix
fix: ## Fix lint violations
golangci-lint run --fix
.PHONY: docker-image .PHONY: docker-image
docker-image: docker-image: clean-release _output/bin/dex
@sudo docker build -t $(DOCKER_IMAGE) . @sudo docker build -t $(DOCKER_IMAGE) .
.PHONY: verify-proto .PHONY: proto
verify-proto: proto proto: api/api.pb.go server/internal/types.pb.go
@./scripts/git-diff
clean: api/api.pb.go: api/api.proto bin/protoc bin/protoc-gen-go
@protoc --go_out=plugins=grpc:. api/*.proto
server/internal/types.pb.go: server/internal/types.proto bin/protoc bin/protoc-gen-go
@protoc --go_out=. server/internal/*.proto
bin/protoc: scripts/get-protoc
@./scripts/get-protoc bin/protoc
bin/protoc-gen-go:
@go install -v $(REPO_PATH)/vendor/github.com/golang/protobuf/protoc-gen-go
.PHONY: check-go-version
check-go-version:
@./scripts/check-go-version
clean: clean-release
@rm -rf bin/ @rm -rf bin/
testall: testrace .PHONY: clean-release
clean-release:
@rm -rf _output/
testall: testrace vet fmt lint
FORCE: FORCE:
.PHONY: test testrace testall .PHONY: test testrace vet fmt lint testall
.PHONY: proto
proto:
@protoc --go_out=paths=source_relative:. --go-grpc_out=paths=source_relative:. api/v2/*.proto
@protoc --go_out=paths=source_relative:. --go-grpc_out=paths=source_relative:. api/*.proto
#@cp api/v2/*.proto api/
.PHONY: proto-internal
proto-internal:
@protoc --go_out=paths=source_relative:. server/internal/*.proto
# Dependency versions
GOLANGCI_VERSION = 1.46.0
GOTESTSUM_VERSION ?= 1.7.0
PROTOC_VERSION = 3.15.6
PROTOC_GEN_GO_VERSION = 1.26.0
PROTOC_GEN_GO_GRPC_VERSION = 1.1.0
KIND_VERSION = 0.11.1
deps: bin/gotestsum bin/golangci-lint bin/protoc bin/protoc-gen-go bin/protoc-gen-go-grpc bin/kind
bin/gotestsum:
@mkdir -p bin
curl -L https://github.com/gotestyourself/gotestsum/releases/download/v${GOTESTSUM_VERSION}/gotestsum_${GOTESTSUM_VERSION}_$(shell uname | tr A-Z a-z)_amd64.tar.gz | tar -zOxf - gotestsum > ./bin/gotestsum
@chmod +x ./bin/gotestsum
bin/golangci-lint:
@mkdir -p bin
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | BINARY=golangci-lint bash -s -- v${GOLANGCI_VERSION}
bin/protoc:
@mkdir -p bin/protoc
ifeq ($(shell uname | tr A-Z a-z), darwin)
curl -L https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-osx-x86_64.zip > bin/protoc.zip
endif
ifeq ($(shell uname | tr A-Z a-z), linux)
curl -L https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-x86_64.zip > bin/protoc.zip
endif
unzip bin/protoc.zip -d bin/protoc
rm bin/protoc.zip
bin/protoc-gen-go:
@mkdir -p bin
curl -L https://github.com/protocolbuffers/protobuf-go/releases/download/v${PROTOC_GEN_GO_VERSION}/protoc-gen-go.v${PROTOC_GEN_GO_VERSION}.$(shell uname | tr A-Z a-z).amd64.tar.gz | tar -zOxf - protoc-gen-go > ./bin/protoc-gen-go
@chmod +x ./bin/protoc-gen-go
bin/protoc-gen-go-grpc:
@mkdir -p bin
curl -L https://github.com/grpc/grpc-go/releases/download/cmd/protoc-gen-go-grpc/v${PROTOC_GEN_GO_GRPC_VERSION}/protoc-gen-go-grpc.v${PROTOC_GEN_GO_GRPC_VERSION}.$(shell uname | tr A-Z a-z).amd64.tar.gz | tar -zOxf - ./protoc-gen-go-grpc > ./bin/protoc-gen-go-grpc
@chmod +x ./bin/protoc-gen-go-grpc
bin/kind:
@mkdir -p bin
curl -L https://github.com/kubernetes-sigs/kind/releases/download/v${KIND_VERSION}/kind-$(shell uname | tr A-Z a-z)-amd64 > ./bin/kind
@chmod +x ./bin/kind

152
README.md
View file

@ -1,143 +1,61 @@
# dex - A federated OpenID Connect provider # dex - A federated OpenID Connect provider
![GitHub Workflow Status](https://img.shields.io/github/workflow/status/dexidp/dex/CI?style=flat-square) [![Travis](https://api.travis-ci.org/coreos/dex.svg)](https://travis-ci.org/coreos/dex)
[![Go Report Card](https://goreportcard.com/badge/github.com/dexidp/dex?style=flat-square)](https://goreportcard.com/report/github.com/dexidp/dex) [![GoDoc](https://godoc.org/github.com/coreos/dex?status.svg)](https://godoc.org/github.com/coreos/dex)
[![Gitpod ready-to-code](https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod&style=flat-square)](https://gitpod.io/#https://github.com/dexidp/dex) [![Go Report Card](https://goreportcard.com/badge/github.com/coreos/dex)](https://goreportcard.com/report/github.com/coreos/dex)
![logo](docs/logos/dex-horizontal-color.png) ![logo](Documentation/logos/dex-horizontal-color.png)
Dex is an identity service that uses [OpenID Connect][openid-connect] to drive authentication for other apps. Dex is an OpenID Connect server that connects to other identity providers. Clients use a standards-based OAuth2 flow to login users, while the actual authentication is performed by established user management systems such as Google, GitHub, FreeIPA, etc.
Dex acts as a portal to other identity providers through ["connectors."](#connectors) This lets dex defer authentication to LDAP servers, SAML providers, or established identity providers like GitHub, Google, and Active Directory. Clients write their authentication logic once to talk to dex, then dex handles the protocols for a given backend. [OpenID Connect][openid-connect] is a flavor of OAuth that builds on top of OAuth2 using the JOSE standards. This allows dex to provide:
## ID Tokens * Short-lived, signed tokens with standard fields (such as email) issued on behalf of users.
* "well-known" discovery of OAuth2 endpoints.
* OAuth2 mechanisms such as refresh tokens and revocation for long term access.
* Automatic signing key rotation.
ID Tokens are an OAuth2 extension introduced by OpenID Connect and dex's primary feature. ID Tokens are [JSON Web Tokens][jwt-io] (JWTs) signed by dex and returned as part of the OAuth2 response that attest to the end user's identity. An example JWT might look like: Standards-based token responses allows applications to interact with any OpenID Connect server instead of writing backend specific "access_token" dances. Systems that can already consume ID Tokens issued by dex include:
```
eyJhbGciOiJSUzI1NiIsImtpZCI6IjlkNDQ3NDFmNzczYjkzOGNmNjVkZDMyNjY4NWI4NjE4MGMzMjRkOTkifQ.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjU1NTYvZGV4Iiwic3ViIjoiQ2djeU16UXlOelE1RWdabmFYUm9kV0kiLCJhdWQiOiJleGFtcGxlLWFwcCIsImV4cCI6MTQ5Mjg4MjA0MiwiaWF0IjoxNDkyNzk1NjQyLCJhdF9oYXNoIjoiYmk5NmdPWFpTaHZsV1l0YWw5RXFpdyIsImVtYWlsIjoiZXJpYy5jaGlhbmdAY29yZW9zLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJncm91cHMiOlsiYWRtaW5zIiwiZGV2ZWxvcGVycyJdLCJuYW1lIjoiRXJpYyBDaGlhbmcifQ.OhROPq_0eP-zsQRjg87KZ4wGkjiQGnTi5QuG877AdJDb3R2ZCOk2Vkf5SdP8cPyb3VMqL32G4hLDayniiv8f1_ZXAde0sKrayfQ10XAXFgZl_P1yilkLdknxn6nbhDRVllpWcB12ki9vmAxklAr0B1C4kr5nI3-BZLrFcUR5sQbxwJj4oW1OuG6jJCNGHXGNTBTNEaM28eD-9nhfBeuBTzzO7BKwPsojjj4C9ogU4JQhGvm_l4yfVi0boSx8c0FX3JsiB0yLa1ZdJVWVl9m90XmbWRSD85pNDQHcWZP9hR6CMgbvGkZsgjG32qeRwUL_eNkNowSBNWLrGNPoON1gMg
```
ID Tokens contains standard claims assert which client app logged the user in, when the token expires, and the identity of the user.
```json
{
"iss": "http://127.0.0.1:5556/dex",
"sub": "CgcyMzQyNzQ5EgZnaXRodWI",
"aud": "example-app",
"exp": 1492882042,
"iat": 1492795642,
"at_hash": "bi96gOXZShvlWYtal9Eqiw",
"email": "jane.doe@coreos.com",
"email_verified": true,
"groups": [
"admins",
"developers"
],
"name": "Jane Doe"
}
```
Because these tokens are signed by dex and [contain standard-based claims][standard-claims] other services can consume them as service-to-service credentials. Systems that can already consume OpenID Connect ID Tokens issued by dex include:
* [Kubernetes][kubernetes] * [Kubernetes][kubernetes]
* [AWS STS][aws-sts] * [AWS STS][aws-sts]
For details on how to request or validate an ID Token, see [_"Writing apps that use dex"_][using-dex]. ## Kubernetes + dex
## Kubernetes and Dex Dex's main production use is as an auth-N addon in CoreOS's enterprise Kubernetes solution, [Tectonic][tectonic]. Dex runs natively on top of any Kubernetes cluster using Third Party Resources and can drive API server authentication through the OpenID Connect plugin. Clients, such as the [Tectonic Console][tectonic-console] and `kubectl`, can act on behalf users who can login to the cluster through any identity provider dex supports.
Dex runs natively on top of any Kubernetes cluster using Custom Resource Definitions and can drive API server authentication through the OpenID Connect plugin. Clients, such as the [`kubernetes-dashboard`](https://github.com/kubernetes/dashboard) and `kubectl`, can act on behalf of users who can login to the cluster through any identity provider dex supports. More docs for running dex as a Kubernetes authenticator can be found [here](Documentation/kubernetes.md).
* More docs for running dex as a Kubernetes authenticator can be found [here](https://dexidp.io/docs/kubernetes/).
* You can find more about companies and projects, which uses dex, [here](./ADOPTERS.md).
## Connectors
When a user logs in through dex, the user's identity is usually stored in another user-management system: a LDAP directory, a GitHub org, etc. Dex acts as a shim between a client app and the upstream identity provider. The client only needs to understand OpenID Connect to query dex, while dex implements an array of protocols for querying other user-management systems.
![](docs/img/dex-flow.png)
A "connector" is a strategy used by dex for authenticating a user against another identity provider. Dex implements connectors that target specific platforms such as GitHub, LinkedIn, and Microsoft as well as established protocols like LDAP and SAML.
Depending on the connectors limitations in protocols can prevent dex from issuing [refresh tokens][scopes] or returning [group membership][scopes] claims. For example, because SAML doesn't provide a non-interactive way to refresh assertions, if a user logs in through the SAML connector dex won't issue a refresh token to its client. Refresh token support is required for clients that require offline access, such as `kubectl`.
Dex implements the following connectors:
| Name | supports refresh tokens | supports groups claim | supports preferred_username claim | status | notes |
| ---- | ----------------------- | --------------------- | --------------------------------- | ------ | ----- |
| [LDAP](https://dexidp.io/docs/connectors/ldap/) | yes | yes | yes | stable | |
| [GitHub](https://dexidp.io/docs/connectors/github/) | yes | yes | yes | stable | |
| [SAML 2.0](https://dexidp.io/docs/connectors/saml/) | no | yes | no | stable | WARNING: Unmaintained and likely vulnerable to auth bypasses ([#1884](https://github.com/dexidp/dex/discussions/1884)) |
| [GitLab](https://dexidp.io/docs/connectors/gitlab/) | yes | yes | yes | beta | |
| [OpenID Connect](https://dexidp.io/docs/connectors/oidc/) | yes | yes | yes | beta | Includes Salesforce, Azure, etc. |
| [OAuth 2.0](https://dexidp.io/docs/connectors/oauth/) | no | yes | yes | alpha | |
| [Google](https://dexidp.io/docs/connectors/google/) | yes | yes | yes | alpha | |
| [LinkedIn](https://dexidp.io/docs/connectors/linkedin/) | yes | no | no | beta | |
| [Microsoft](https://dexidp.io/docs/connectors/microsoft/) | yes | yes | no | beta | |
| [AuthProxy](https://dexidp.io/docs/connectors/authproxy/) | no | yes | no | alpha | Authentication proxies such as Apache2 mod_auth, etc. |
| [Bitbucket Cloud](https://dexidp.io/docs/connectors/bitbucketcloud/) | yes | yes | no | alpha | |
| [OpenShift](https://dexidp.io/docs/connectors/openshift/) | no | yes | no | alpha | |
| [Atlassian Crowd](https://dexidp.io/docs/connectors/atlassiancrowd/) | yes | yes | yes * | beta | preferred_username claim must be configured through config |
| [Gitea](https://dexidp.io/docs/connectors/gitea/) | yes | no | yes | beta | |
| [OpenStack Keystone](https://dexidp.io/docs/connectors/keystone/) | yes | yes | no | alpha | |
Stable, beta, and alpha are defined as:
* Stable: well tested, in active use, and will not change in backward incompatible ways.
* Beta: tested and unlikely to change in backward incompatible ways.
* Alpha: may be untested by core maintainers and is subject to change in backward incompatible ways.
All changes or deprecations of connector features will be announced in the [release notes][release-notes].
## Documentation ## Documentation
* [Getting started](https://dexidp.io/docs/getting-started/) * [Getting started](Documentation/getting-started.md)
* [Intro to OpenID Connect](https://dexidp.io/docs/openid-connect/) * [What's new in v2](Documentation/v2.md)
* [Writing apps that use dex][using-dex] * [Custom scopes, claims, and client features](Documentation/custom-scopes-claims-clients.md)
* [What's new in v2](https://dexidp.io/docs/v2/) * [Storage options](Documentation/storage.md)
* [Custom scopes, claims, and client features](https://dexidp.io/docs/custom-scopes-claims-clients/) * [Intro to OpenID Connect](Documentation/openid-connect.md)
* [Storage options](https://dexidp.io/docs/storage/) * [gRPC API](Documentation/api.md)
* [gRPC API](https://dexidp.io/docs/api/) * [Using Kubernetes with dex](Documentation/kubernetes.md)
* [Using Kubernetes with dex](https://dexidp.io/docs/kubernetes/) * Identity provider logins
* [LDAP](Documentation/ldap-connector.md)
* [GitHub](Documentation/github-connector.md)
* [GitLab](Documentation/gitlab-connector.md)
* [SAML 2.0](Documentation/saml-connector.md)
* [OpenID Connect](Documentation/oidc-connector.md) (includes Google, Salesforce, Azure, etc.)
* Client libraries * Client libraries
* [Go][go-oidc] * [Go][go-oidc]
## Reporting a vulnerability
Please see our [security policy](.github/SECURITY.md) for details about reporting vulnerabilities.
## Getting help ## Getting help
- For feature requests and bugs, file an [issue](https://github.com/dexidp/dex/issues). * For bugs and feature requests (including documentation!), file an [issue][issues].
- For general discussion about both using and developing Dex: * For general discussion about both using and developing dex, join the [dex-dev][dex-dev] mailing list.
- join the [#dexidp](https://cloud-native.slack.com/messages/dexidp) on the CNCF Slack * For more details on dex development plans, check out the GitHub [milestones][milestones].
- open a new [discussion](https://github.com/dexidp/dex/discussions)
- join the [dex-dev](https://groups.google.com/forum/#!forum/dex-dev) mailing list
[openid-connect]: https://openid.net/connect/ [openid-connect]: https://openid.net/connect/
[standard-claims]: https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
[scopes]: https://dexidp.io/docs/custom-scopes-claims-clients/#scopes
[using-dex]: https://dexidp.io/docs/using-dex/
[jwt-io]: https://jwt.io/
[kubernetes]: http://kubernetes.io/docs/admin/authentication/#openid-connect-tokens [kubernetes]: http://kubernetes.io/docs/admin/authentication/#openid-connect-tokens
[aws-sts]: https://docs.aws.amazon.com/STS/latest/APIReference/Welcome.html [aws-sts]: https://docs.aws.amazon.com/STS/latest/APIReference/Welcome.html
[tectonic]: https://tectonic.com/
[tectonic-console]: https://tectonic.com/enterprise/docs/latest/usage/index.html#tectonic-console
[go-oidc]: https://github.com/coreos/go-oidc [go-oidc]: https://github.com/coreos/go-oidc
[issue-1065]: https://github.com/dexidp/dex/issues/1065 [issues]: https://github.com/coreos/dex/issues
[release-notes]: https://github.com/dexidp/dex/releases [dex-dev]: https://groups.google.com/forum/#!forum/dex-dev
[milestones]: https://github.com/coreos/dex/milestones
## Development
When all coding and testing is done, please run the test suite:
```shell
make testall
```
For the best developer experience, install [Nix](https://builtwithnix.org/) and [direnv](https://direnv.net/).
Alternatively, install Go and Docker manually or using a package manager. Install the rest of the dependencies by running `make deps`.
## License
The project is licensed under the [Apache License, Version 2.0](LICENSE).

File diff suppressed because it is too large Load diff

View file

@ -2,9 +2,6 @@ syntax = "proto3";
package api; package api;
option java_package = "com.coreos.dex.api";
option go_package = "github.com/dexidp/dex/api";
// Client represents an OAuth2 client. // Client represents an OAuth2 client.
message Client { message Client {
string id = 1; string id = 1;
@ -38,20 +35,6 @@ message DeleteClientResp {
bool not_found = 1; bool not_found = 1;
} }
// UpdateClientReq is a request to update an existing client.
message UpdateClientReq {
string id = 1;
repeated string redirect_uris = 2;
repeated string trusted_peers = 3;
string name = 4;
string logo_url = 5;
}
// UpdateClientResp returns the response from updating a client.
message UpdateClientResp {
bool not_found = 1;
}
// TODO(ericchiang): expand this. // TODO(ericchiang): expand this.
// Password is an email for password mapping managed by the storage. // Password is an email for password mapping managed by the storage.
@ -150,22 +133,10 @@ message RevokeRefreshResp {
bool not_found = 1; bool not_found = 1;
} }
message VerifyPasswordReq {
string email = 1;
string password = 2;
}
message VerifyPasswordResp {
bool verified = 1;
bool not_found = 2;
}
// Dex represents the dex gRPC service. // Dex represents the dex gRPC service.
service Dex { service Dex {
// CreateClient creates a client. // CreateClient creates a client.
rpc CreateClient(CreateClientReq) returns (CreateClientResp) {}; rpc CreateClient(CreateClientReq) returns (CreateClientResp) {};
// UpdateClient updates an existing client
rpc UpdateClient(UpdateClientReq) returns (UpdateClientResp) {};
// DeleteClient deletes the provided client. // DeleteClient deletes the provided client.
rpc DeleteClient(DeleteClientReq) returns (DeleteClientResp) {}; rpc DeleteClient(DeleteClientReq) returns (DeleteClientResp) {};
// CreatePassword creates a password. // CreatePassword creates a password.
@ -184,6 +155,4 @@ service Dex {
// //
// Note that each user-client pair can have only one refresh token at a time. // Note that each user-client pair can have only one refresh token at a time.
rpc RevokeRefresh(RevokeRefreshReq) returns (RevokeRefreshResp) {}; rpc RevokeRefresh(RevokeRefreshReq) returns (RevokeRefreshResp) {};
// VerifyPassword returns whether a password matches a hash for a specific email or not.
rpc VerifyPassword(VerifyPasswordReq) returns (VerifyPasswordResp) {};
} }

View file

@ -1,487 +0,0 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
package api
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.32.0 or later.
const _ = grpc.SupportPackageIsVersion7
// DexClient is the client API for Dex service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type DexClient interface {
// CreateClient creates a client.
CreateClient(ctx context.Context, in *CreateClientReq, opts ...grpc.CallOption) (*CreateClientResp, error)
// UpdateClient updates an existing client
UpdateClient(ctx context.Context, in *UpdateClientReq, opts ...grpc.CallOption) (*UpdateClientResp, error)
// DeleteClient deletes the provided client.
DeleteClient(ctx context.Context, in *DeleteClientReq, opts ...grpc.CallOption) (*DeleteClientResp, error)
// CreatePassword creates a password.
CreatePassword(ctx context.Context, in *CreatePasswordReq, opts ...grpc.CallOption) (*CreatePasswordResp, error)
// UpdatePassword modifies existing password.
UpdatePassword(ctx context.Context, in *UpdatePasswordReq, opts ...grpc.CallOption) (*UpdatePasswordResp, error)
// DeletePassword deletes the password.
DeletePassword(ctx context.Context, in *DeletePasswordReq, opts ...grpc.CallOption) (*DeletePasswordResp, error)
// ListPassword lists all password entries.
ListPasswords(ctx context.Context, in *ListPasswordReq, opts ...grpc.CallOption) (*ListPasswordResp, error)
// GetVersion returns version information of the server.
GetVersion(ctx context.Context, in *VersionReq, opts ...grpc.CallOption) (*VersionResp, error)
// ListRefresh lists all the refresh token entries for a particular user.
ListRefresh(ctx context.Context, in *ListRefreshReq, opts ...grpc.CallOption) (*ListRefreshResp, error)
// RevokeRefresh revokes the refresh token for the provided user-client pair.
//
// Note that each user-client pair can have only one refresh token at a time.
RevokeRefresh(ctx context.Context, in *RevokeRefreshReq, opts ...grpc.CallOption) (*RevokeRefreshResp, error)
// VerifyPassword returns whether a password matches a hash for a specific email or not.
VerifyPassword(ctx context.Context, in *VerifyPasswordReq, opts ...grpc.CallOption) (*VerifyPasswordResp, error)
}
type dexClient struct {
cc grpc.ClientConnInterface
}
func NewDexClient(cc grpc.ClientConnInterface) DexClient {
return &dexClient{cc}
}
func (c *dexClient) CreateClient(ctx context.Context, in *CreateClientReq, opts ...grpc.CallOption) (*CreateClientResp, error) {
out := new(CreateClientResp)
err := c.cc.Invoke(ctx, "/api.Dex/CreateClient", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *dexClient) UpdateClient(ctx context.Context, in *UpdateClientReq, opts ...grpc.CallOption) (*UpdateClientResp, error) {
out := new(UpdateClientResp)
err := c.cc.Invoke(ctx, "/api.Dex/UpdateClient", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *dexClient) DeleteClient(ctx context.Context, in *DeleteClientReq, opts ...grpc.CallOption) (*DeleteClientResp, error) {
out := new(DeleteClientResp)
err := c.cc.Invoke(ctx, "/api.Dex/DeleteClient", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *dexClient) CreatePassword(ctx context.Context, in *CreatePasswordReq, opts ...grpc.CallOption) (*CreatePasswordResp, error) {
out := new(CreatePasswordResp)
err := c.cc.Invoke(ctx, "/api.Dex/CreatePassword", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *dexClient) UpdatePassword(ctx context.Context, in *UpdatePasswordReq, opts ...grpc.CallOption) (*UpdatePasswordResp, error) {
out := new(UpdatePasswordResp)
err := c.cc.Invoke(ctx, "/api.Dex/UpdatePassword", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *dexClient) DeletePassword(ctx context.Context, in *DeletePasswordReq, opts ...grpc.CallOption) (*DeletePasswordResp, error) {
out := new(DeletePasswordResp)
err := c.cc.Invoke(ctx, "/api.Dex/DeletePassword", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *dexClient) ListPasswords(ctx context.Context, in *ListPasswordReq, opts ...grpc.CallOption) (*ListPasswordResp, error) {
out := new(ListPasswordResp)
err := c.cc.Invoke(ctx, "/api.Dex/ListPasswords", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *dexClient) GetVersion(ctx context.Context, in *VersionReq, opts ...grpc.CallOption) (*VersionResp, error) {
out := new(VersionResp)
err := c.cc.Invoke(ctx, "/api.Dex/GetVersion", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *dexClient) ListRefresh(ctx context.Context, in *ListRefreshReq, opts ...grpc.CallOption) (*ListRefreshResp, error) {
out := new(ListRefreshResp)
err := c.cc.Invoke(ctx, "/api.Dex/ListRefresh", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *dexClient) RevokeRefresh(ctx context.Context, in *RevokeRefreshReq, opts ...grpc.CallOption) (*RevokeRefreshResp, error) {
out := new(RevokeRefreshResp)
err := c.cc.Invoke(ctx, "/api.Dex/RevokeRefresh", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *dexClient) VerifyPassword(ctx context.Context, in *VerifyPasswordReq, opts ...grpc.CallOption) (*VerifyPasswordResp, error) {
out := new(VerifyPasswordResp)
err := c.cc.Invoke(ctx, "/api.Dex/VerifyPassword", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
// DexServer is the server API for Dex service.
// All implementations must embed UnimplementedDexServer
// for forward compatibility
type DexServer interface {
// CreateClient creates a client.
CreateClient(context.Context, *CreateClientReq) (*CreateClientResp, error)
// UpdateClient updates an existing client
UpdateClient(context.Context, *UpdateClientReq) (*UpdateClientResp, error)
// DeleteClient deletes the provided client.
DeleteClient(context.Context, *DeleteClientReq) (*DeleteClientResp, error)
// CreatePassword creates a password.
CreatePassword(context.Context, *CreatePasswordReq) (*CreatePasswordResp, error)
// UpdatePassword modifies existing password.
UpdatePassword(context.Context, *UpdatePasswordReq) (*UpdatePasswordResp, error)
// DeletePassword deletes the password.
DeletePassword(context.Context, *DeletePasswordReq) (*DeletePasswordResp, error)
// ListPassword lists all password entries.
ListPasswords(context.Context, *ListPasswordReq) (*ListPasswordResp, error)
// GetVersion returns version information of the server.
GetVersion(context.Context, *VersionReq) (*VersionResp, error)
// ListRefresh lists all the refresh token entries for a particular user.
ListRefresh(context.Context, *ListRefreshReq) (*ListRefreshResp, error)
// RevokeRefresh revokes the refresh token for the provided user-client pair.
//
// Note that each user-client pair can have only one refresh token at a time.
RevokeRefresh(context.Context, *RevokeRefreshReq) (*RevokeRefreshResp, error)
// VerifyPassword returns whether a password matches a hash for a specific email or not.
VerifyPassword(context.Context, *VerifyPasswordReq) (*VerifyPasswordResp, error)
mustEmbedUnimplementedDexServer()
}
// UnimplementedDexServer must be embedded to have forward compatible implementations.
type UnimplementedDexServer struct {
}
func (UnimplementedDexServer) CreateClient(context.Context, *CreateClientReq) (*CreateClientResp, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateClient not implemented")
}
func (UnimplementedDexServer) UpdateClient(context.Context, *UpdateClientReq) (*UpdateClientResp, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateClient not implemented")
}
func (UnimplementedDexServer) DeleteClient(context.Context, *DeleteClientReq) (*DeleteClientResp, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteClient not implemented")
}
func (UnimplementedDexServer) CreatePassword(context.Context, *CreatePasswordReq) (*CreatePasswordResp, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreatePassword not implemented")
}
func (UnimplementedDexServer) UpdatePassword(context.Context, *UpdatePasswordReq) (*UpdatePasswordResp, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdatePassword not implemented")
}
func (UnimplementedDexServer) DeletePassword(context.Context, *DeletePasswordReq) (*DeletePasswordResp, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeletePassword not implemented")
}
func (UnimplementedDexServer) ListPasswords(context.Context, *ListPasswordReq) (*ListPasswordResp, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListPasswords not implemented")
}
func (UnimplementedDexServer) GetVersion(context.Context, *VersionReq) (*VersionResp, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetVersion not implemented")
}
func (UnimplementedDexServer) ListRefresh(context.Context, *ListRefreshReq) (*ListRefreshResp, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListRefresh not implemented")
}
func (UnimplementedDexServer) RevokeRefresh(context.Context, *RevokeRefreshReq) (*RevokeRefreshResp, error) {
return nil, status.Errorf(codes.Unimplemented, "method RevokeRefresh not implemented")
}
func (UnimplementedDexServer) VerifyPassword(context.Context, *VerifyPasswordReq) (*VerifyPasswordResp, error) {
return nil, status.Errorf(codes.Unimplemented, "method VerifyPassword not implemented")
}
func (UnimplementedDexServer) mustEmbedUnimplementedDexServer() {}
// UnsafeDexServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to DexServer will
// result in compilation errors.
type UnsafeDexServer interface {
mustEmbedUnimplementedDexServer()
}
func RegisterDexServer(s grpc.ServiceRegistrar, srv DexServer) {
s.RegisterService(&Dex_ServiceDesc, srv)
}
func _Dex_CreateClient_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateClientReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DexServer).CreateClient(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/api.Dex/CreateClient",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DexServer).CreateClient(ctx, req.(*CreateClientReq))
}
return interceptor(ctx, in, info, handler)
}
func _Dex_UpdateClient_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateClientReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DexServer).UpdateClient(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/api.Dex/UpdateClient",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DexServer).UpdateClient(ctx, req.(*UpdateClientReq))
}
return interceptor(ctx, in, info, handler)
}
func _Dex_DeleteClient_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteClientReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DexServer).DeleteClient(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/api.Dex/DeleteClient",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DexServer).DeleteClient(ctx, req.(*DeleteClientReq))
}
return interceptor(ctx, in, info, handler)
}
func _Dex_CreatePassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreatePasswordReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DexServer).CreatePassword(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/api.Dex/CreatePassword",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DexServer).CreatePassword(ctx, req.(*CreatePasswordReq))
}
return interceptor(ctx, in, info, handler)
}
func _Dex_UpdatePassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdatePasswordReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DexServer).UpdatePassword(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/api.Dex/UpdatePassword",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DexServer).UpdatePassword(ctx, req.(*UpdatePasswordReq))
}
return interceptor(ctx, in, info, handler)
}
func _Dex_DeletePassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeletePasswordReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DexServer).DeletePassword(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/api.Dex/DeletePassword",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DexServer).DeletePassword(ctx, req.(*DeletePasswordReq))
}
return interceptor(ctx, in, info, handler)
}
func _Dex_ListPasswords_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListPasswordReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DexServer).ListPasswords(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/api.Dex/ListPasswords",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DexServer).ListPasswords(ctx, req.(*ListPasswordReq))
}
return interceptor(ctx, in, info, handler)
}
func _Dex_GetVersion_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(VersionReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DexServer).GetVersion(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/api.Dex/GetVersion",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DexServer).GetVersion(ctx, req.(*VersionReq))
}
return interceptor(ctx, in, info, handler)
}
func _Dex_ListRefresh_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListRefreshReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DexServer).ListRefresh(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/api.Dex/ListRefresh",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DexServer).ListRefresh(ctx, req.(*ListRefreshReq))
}
return interceptor(ctx, in, info, handler)
}
func _Dex_RevokeRefresh_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RevokeRefreshReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DexServer).RevokeRefresh(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/api.Dex/RevokeRefresh",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DexServer).RevokeRefresh(ctx, req.(*RevokeRefreshReq))
}
return interceptor(ctx, in, info, handler)
}
func _Dex_VerifyPassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(VerifyPasswordReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DexServer).VerifyPassword(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/api.Dex/VerifyPassword",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DexServer).VerifyPassword(ctx, req.(*VerifyPasswordReq))
}
return interceptor(ctx, in, info, handler)
}
// Dex_ServiceDesc is the grpc.ServiceDesc for Dex service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var Dex_ServiceDesc = grpc.ServiceDesc{
ServiceName: "api.Dex",
HandlerType: (*DexServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "CreateClient",
Handler: _Dex_CreateClient_Handler,
},
{
MethodName: "UpdateClient",
Handler: _Dex_UpdateClient_Handler,
},
{
MethodName: "DeleteClient",
Handler: _Dex_DeleteClient_Handler,
},
{
MethodName: "CreatePassword",
Handler: _Dex_CreatePassword_Handler,
},
{
MethodName: "UpdatePassword",
Handler: _Dex_UpdatePassword_Handler,
},
{
MethodName: "DeletePassword",
Handler: _Dex_DeletePassword_Handler,
},
{
MethodName: "ListPasswords",
Handler: _Dex_ListPasswords_Handler,
},
{
MethodName: "GetVersion",
Handler: _Dex_GetVersion_Handler,
},
{
MethodName: "ListRefresh",
Handler: _Dex_ListRefresh_Handler,
},
{
MethodName: "RevokeRefresh",
Handler: _Dex_RevokeRefresh_Handler,
},
{
MethodName: "VerifyPassword",
Handler: _Dex_VerifyPassword_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "api/api.proto",
}

File diff suppressed because it is too large Load diff

View file

@ -1,189 +0,0 @@
syntax = "proto3";
package api;
option java_package = "com.coreos.dex.api";
option go_package = "github.com/dexidp/dex/api/v2;api";
// Client represents an OAuth2 client.
message Client {
string id = 1;
string secret = 2;
repeated string redirect_uris = 3;
repeated string trusted_peers = 4;
bool public = 5;
string name = 6;
string logo_url = 7;
}
// CreateClientReq is a request to make a client.
message CreateClientReq {
Client client = 1;
}
// CreateClientResp returns the response from creating a client.
message CreateClientResp {
bool already_exists = 1;
Client client = 2;
}
// DeleteClientReq is a request to delete a client.
message DeleteClientReq {
// The ID of the client.
string id = 1;
}
// DeleteClientResp determines if the client is deleted successfully.
message DeleteClientResp {
bool not_found = 1;
}
// UpdateClientReq is a request to update an existing client.
message UpdateClientReq {
string id = 1;
repeated string redirect_uris = 2;
repeated string trusted_peers = 3;
string name = 4;
string logo_url = 5;
}
// UpdateClientResp returns the response from updating a client.
message UpdateClientResp {
bool not_found = 1;
}
// TODO(ericchiang): expand this.
// Password is an email for password mapping managed by the storage.
message Password {
string email = 1;
// Currently we do not accept plain text passwords. Could be an option in the future.
bytes hash = 2;
string username = 3;
string user_id = 4;
}
// CreatePasswordReq is a request to make a password.
message CreatePasswordReq {
Password password = 1;
}
// CreatePasswordResp returns the response from creating a password.
message CreatePasswordResp {
bool already_exists = 1;
}
// UpdatePasswordReq is a request to modify an existing password.
message UpdatePasswordReq {
// The email used to lookup the password. This field cannot be modified
string email = 1;
bytes new_hash = 2;
string new_username = 3;
}
// UpdatePasswordResp returns the response from modifying an existing password.
message UpdatePasswordResp {
bool not_found = 1;
}
// DeletePasswordReq is a request to delete a password.
message DeletePasswordReq {
string email = 1;
}
// DeletePasswordResp returns the response from deleting a password.
message DeletePasswordResp {
bool not_found = 1;
}
// ListPasswordReq is a request to enumerate passwords.
message ListPasswordReq {}
// ListPasswordResp returns a list of passwords.
message ListPasswordResp {
repeated Password passwords = 1;
}
// VersionReq is a request to fetch version info.
message VersionReq {}
// VersionResp holds the version info of components.
message VersionResp {
// Semantic version of the server.
string server = 1;
// Numeric version of the API. It increases everytime a new call is added to the API.
// Clients should use this info to determine if the server supports specific features.
int32 api = 2;
}
// RefreshTokenRef contains the metadata for a refresh token that is managed by the storage.
message RefreshTokenRef {
// ID of the refresh token.
string id = 1;
string client_id = 2;
int64 created_at = 5;
int64 last_used = 6;
}
// ListRefreshReq is a request to enumerate the refresh tokens of a user.
message ListRefreshReq {
// The "sub" claim returned in the ID Token.
string user_id = 1;
}
// ListRefreshResp returns a list of refresh tokens for a user.
message ListRefreshResp {
repeated RefreshTokenRef refresh_tokens = 1;
}
// RevokeRefreshReq is a request to revoke the refresh token of the user-client pair.
message RevokeRefreshReq {
// The "sub" claim returned in the ID Token.
string user_id = 1;
string client_id = 2;
}
// RevokeRefreshResp determines if the refresh token is revoked successfully.
message RevokeRefreshResp {
// Set to true is refresh token was not found and token could not be revoked.
bool not_found = 1;
}
message VerifyPasswordReq {
string email = 1;
string password = 2;
}
message VerifyPasswordResp {
bool verified = 1;
bool not_found = 2;
}
// Dex represents the dex gRPC service.
service Dex {
// CreateClient creates a client.
rpc CreateClient(CreateClientReq) returns (CreateClientResp) {};
// UpdateClient updates an existing client
rpc UpdateClient(UpdateClientReq) returns (UpdateClientResp) {};
// DeleteClient deletes the provided client.
rpc DeleteClient(DeleteClientReq) returns (DeleteClientResp) {};
// CreatePassword creates a password.
rpc CreatePassword(CreatePasswordReq) returns (CreatePasswordResp) {};
// UpdatePassword modifies existing password.
rpc UpdatePassword(UpdatePasswordReq) returns (UpdatePasswordResp) {};
// DeletePassword deletes the password.
rpc DeletePassword(DeletePasswordReq) returns (DeletePasswordResp) {};
// ListPassword lists all password entries.
rpc ListPasswords(ListPasswordReq) returns (ListPasswordResp) {};
// GetVersion returns version information of the server.
rpc GetVersion(VersionReq) returns (VersionResp) {};
// ListRefresh lists all the refresh token entries for a particular user.
rpc ListRefresh(ListRefreshReq) returns (ListRefreshResp) {};
// RevokeRefresh revokes the refresh token for the provided user-client pair.
//
// Note that each user-client pair can have only one refresh token at a time.
rpc RevokeRefresh(RevokeRefreshReq) returns (RevokeRefreshResp) {};
// VerifyPassword returns whether a password matches a hash for a specific email or not.
rpc VerifyPassword(VerifyPasswordReq) returns (VerifyPasswordResp) {};
}

View file

@ -1,487 +0,0 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
package api
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.32.0 or later.
const _ = grpc.SupportPackageIsVersion7
// DexClient is the client API for Dex service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type DexClient interface {
// CreateClient creates a client.
CreateClient(ctx context.Context, in *CreateClientReq, opts ...grpc.CallOption) (*CreateClientResp, error)
// UpdateClient updates an existing client
UpdateClient(ctx context.Context, in *UpdateClientReq, opts ...grpc.CallOption) (*UpdateClientResp, error)
// DeleteClient deletes the provided client.
DeleteClient(ctx context.Context, in *DeleteClientReq, opts ...grpc.CallOption) (*DeleteClientResp, error)
// CreatePassword creates a password.
CreatePassword(ctx context.Context, in *CreatePasswordReq, opts ...grpc.CallOption) (*CreatePasswordResp, error)
// UpdatePassword modifies existing password.
UpdatePassword(ctx context.Context, in *UpdatePasswordReq, opts ...grpc.CallOption) (*UpdatePasswordResp, error)
// DeletePassword deletes the password.
DeletePassword(ctx context.Context, in *DeletePasswordReq, opts ...grpc.CallOption) (*DeletePasswordResp, error)
// ListPassword lists all password entries.
ListPasswords(ctx context.Context, in *ListPasswordReq, opts ...grpc.CallOption) (*ListPasswordResp, error)
// GetVersion returns version information of the server.
GetVersion(ctx context.Context, in *VersionReq, opts ...grpc.CallOption) (*VersionResp, error)
// ListRefresh lists all the refresh token entries for a particular user.
ListRefresh(ctx context.Context, in *ListRefreshReq, opts ...grpc.CallOption) (*ListRefreshResp, error)
// RevokeRefresh revokes the refresh token for the provided user-client pair.
//
// Note that each user-client pair can have only one refresh token at a time.
RevokeRefresh(ctx context.Context, in *RevokeRefreshReq, opts ...grpc.CallOption) (*RevokeRefreshResp, error)
// VerifyPassword returns whether a password matches a hash for a specific email or not.
VerifyPassword(ctx context.Context, in *VerifyPasswordReq, opts ...grpc.CallOption) (*VerifyPasswordResp, error)
}
type dexClient struct {
cc grpc.ClientConnInterface
}
func NewDexClient(cc grpc.ClientConnInterface) DexClient {
return &dexClient{cc}
}
func (c *dexClient) CreateClient(ctx context.Context, in *CreateClientReq, opts ...grpc.CallOption) (*CreateClientResp, error) {
out := new(CreateClientResp)
err := c.cc.Invoke(ctx, "/api.Dex/CreateClient", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *dexClient) UpdateClient(ctx context.Context, in *UpdateClientReq, opts ...grpc.CallOption) (*UpdateClientResp, error) {
out := new(UpdateClientResp)
err := c.cc.Invoke(ctx, "/api.Dex/UpdateClient", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *dexClient) DeleteClient(ctx context.Context, in *DeleteClientReq, opts ...grpc.CallOption) (*DeleteClientResp, error) {
out := new(DeleteClientResp)
err := c.cc.Invoke(ctx, "/api.Dex/DeleteClient", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *dexClient) CreatePassword(ctx context.Context, in *CreatePasswordReq, opts ...grpc.CallOption) (*CreatePasswordResp, error) {
out := new(CreatePasswordResp)
err := c.cc.Invoke(ctx, "/api.Dex/CreatePassword", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *dexClient) UpdatePassword(ctx context.Context, in *UpdatePasswordReq, opts ...grpc.CallOption) (*UpdatePasswordResp, error) {
out := new(UpdatePasswordResp)
err := c.cc.Invoke(ctx, "/api.Dex/UpdatePassword", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *dexClient) DeletePassword(ctx context.Context, in *DeletePasswordReq, opts ...grpc.CallOption) (*DeletePasswordResp, error) {
out := new(DeletePasswordResp)
err := c.cc.Invoke(ctx, "/api.Dex/DeletePassword", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *dexClient) ListPasswords(ctx context.Context, in *ListPasswordReq, opts ...grpc.CallOption) (*ListPasswordResp, error) {
out := new(ListPasswordResp)
err := c.cc.Invoke(ctx, "/api.Dex/ListPasswords", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *dexClient) GetVersion(ctx context.Context, in *VersionReq, opts ...grpc.CallOption) (*VersionResp, error) {
out := new(VersionResp)
err := c.cc.Invoke(ctx, "/api.Dex/GetVersion", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *dexClient) ListRefresh(ctx context.Context, in *ListRefreshReq, opts ...grpc.CallOption) (*ListRefreshResp, error) {
out := new(ListRefreshResp)
err := c.cc.Invoke(ctx, "/api.Dex/ListRefresh", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *dexClient) RevokeRefresh(ctx context.Context, in *RevokeRefreshReq, opts ...grpc.CallOption) (*RevokeRefreshResp, error) {
out := new(RevokeRefreshResp)
err := c.cc.Invoke(ctx, "/api.Dex/RevokeRefresh", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *dexClient) VerifyPassword(ctx context.Context, in *VerifyPasswordReq, opts ...grpc.CallOption) (*VerifyPasswordResp, error) {
out := new(VerifyPasswordResp)
err := c.cc.Invoke(ctx, "/api.Dex/VerifyPassword", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
// DexServer is the server API for Dex service.
// All implementations must embed UnimplementedDexServer
// for forward compatibility
type DexServer interface {
// CreateClient creates a client.
CreateClient(context.Context, *CreateClientReq) (*CreateClientResp, error)
// UpdateClient updates an existing client
UpdateClient(context.Context, *UpdateClientReq) (*UpdateClientResp, error)
// DeleteClient deletes the provided client.
DeleteClient(context.Context, *DeleteClientReq) (*DeleteClientResp, error)
// CreatePassword creates a password.
CreatePassword(context.Context, *CreatePasswordReq) (*CreatePasswordResp, error)
// UpdatePassword modifies existing password.
UpdatePassword(context.Context, *UpdatePasswordReq) (*UpdatePasswordResp, error)
// DeletePassword deletes the password.
DeletePassword(context.Context, *DeletePasswordReq) (*DeletePasswordResp, error)
// ListPassword lists all password entries.
ListPasswords(context.Context, *ListPasswordReq) (*ListPasswordResp, error)
// GetVersion returns version information of the server.
GetVersion(context.Context, *VersionReq) (*VersionResp, error)
// ListRefresh lists all the refresh token entries for a particular user.
ListRefresh(context.Context, *ListRefreshReq) (*ListRefreshResp, error)
// RevokeRefresh revokes the refresh token for the provided user-client pair.
//
// Note that each user-client pair can have only one refresh token at a time.
RevokeRefresh(context.Context, *RevokeRefreshReq) (*RevokeRefreshResp, error)
// VerifyPassword returns whether a password matches a hash for a specific email or not.
VerifyPassword(context.Context, *VerifyPasswordReq) (*VerifyPasswordResp, error)
mustEmbedUnimplementedDexServer()
}
// UnimplementedDexServer must be embedded to have forward compatible implementations.
type UnimplementedDexServer struct {
}
func (UnimplementedDexServer) CreateClient(context.Context, *CreateClientReq) (*CreateClientResp, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateClient not implemented")
}
func (UnimplementedDexServer) UpdateClient(context.Context, *UpdateClientReq) (*UpdateClientResp, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateClient not implemented")
}
func (UnimplementedDexServer) DeleteClient(context.Context, *DeleteClientReq) (*DeleteClientResp, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteClient not implemented")
}
func (UnimplementedDexServer) CreatePassword(context.Context, *CreatePasswordReq) (*CreatePasswordResp, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreatePassword not implemented")
}
func (UnimplementedDexServer) UpdatePassword(context.Context, *UpdatePasswordReq) (*UpdatePasswordResp, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdatePassword not implemented")
}
func (UnimplementedDexServer) DeletePassword(context.Context, *DeletePasswordReq) (*DeletePasswordResp, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeletePassword not implemented")
}
func (UnimplementedDexServer) ListPasswords(context.Context, *ListPasswordReq) (*ListPasswordResp, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListPasswords not implemented")
}
func (UnimplementedDexServer) GetVersion(context.Context, *VersionReq) (*VersionResp, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetVersion not implemented")
}
func (UnimplementedDexServer) ListRefresh(context.Context, *ListRefreshReq) (*ListRefreshResp, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListRefresh not implemented")
}
func (UnimplementedDexServer) RevokeRefresh(context.Context, *RevokeRefreshReq) (*RevokeRefreshResp, error) {
return nil, status.Errorf(codes.Unimplemented, "method RevokeRefresh not implemented")
}
func (UnimplementedDexServer) VerifyPassword(context.Context, *VerifyPasswordReq) (*VerifyPasswordResp, error) {
return nil, status.Errorf(codes.Unimplemented, "method VerifyPassword not implemented")
}
func (UnimplementedDexServer) mustEmbedUnimplementedDexServer() {}
// UnsafeDexServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to DexServer will
// result in compilation errors.
type UnsafeDexServer interface {
mustEmbedUnimplementedDexServer()
}
func RegisterDexServer(s grpc.ServiceRegistrar, srv DexServer) {
s.RegisterService(&Dex_ServiceDesc, srv)
}
func _Dex_CreateClient_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateClientReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DexServer).CreateClient(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/api.Dex/CreateClient",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DexServer).CreateClient(ctx, req.(*CreateClientReq))
}
return interceptor(ctx, in, info, handler)
}
func _Dex_UpdateClient_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateClientReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DexServer).UpdateClient(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/api.Dex/UpdateClient",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DexServer).UpdateClient(ctx, req.(*UpdateClientReq))
}
return interceptor(ctx, in, info, handler)
}
func _Dex_DeleteClient_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteClientReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DexServer).DeleteClient(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/api.Dex/DeleteClient",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DexServer).DeleteClient(ctx, req.(*DeleteClientReq))
}
return interceptor(ctx, in, info, handler)
}
func _Dex_CreatePassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreatePasswordReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DexServer).CreatePassword(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/api.Dex/CreatePassword",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DexServer).CreatePassword(ctx, req.(*CreatePasswordReq))
}
return interceptor(ctx, in, info, handler)
}
func _Dex_UpdatePassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdatePasswordReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DexServer).UpdatePassword(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/api.Dex/UpdatePassword",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DexServer).UpdatePassword(ctx, req.(*UpdatePasswordReq))
}
return interceptor(ctx, in, info, handler)
}
func _Dex_DeletePassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeletePasswordReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DexServer).DeletePassword(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/api.Dex/DeletePassword",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DexServer).DeletePassword(ctx, req.(*DeletePasswordReq))
}
return interceptor(ctx, in, info, handler)
}
func _Dex_ListPasswords_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListPasswordReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DexServer).ListPasswords(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/api.Dex/ListPasswords",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DexServer).ListPasswords(ctx, req.(*ListPasswordReq))
}
return interceptor(ctx, in, info, handler)
}
func _Dex_GetVersion_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(VersionReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DexServer).GetVersion(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/api.Dex/GetVersion",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DexServer).GetVersion(ctx, req.(*VersionReq))
}
return interceptor(ctx, in, info, handler)
}
func _Dex_ListRefresh_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListRefreshReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DexServer).ListRefresh(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/api.Dex/ListRefresh",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DexServer).ListRefresh(ctx, req.(*ListRefreshReq))
}
return interceptor(ctx, in, info, handler)
}
func _Dex_RevokeRefresh_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RevokeRefreshReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DexServer).RevokeRefresh(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/api.Dex/RevokeRefresh",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DexServer).RevokeRefresh(ctx, req.(*RevokeRefreshReq))
}
return interceptor(ctx, in, info, handler)
}
func _Dex_VerifyPassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(VerifyPasswordReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DexServer).VerifyPassword(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/api.Dex/VerifyPassword",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DexServer).VerifyPassword(ctx, req.(*VerifyPasswordReq))
}
return interceptor(ctx, in, info, handler)
}
// Dex_ServiceDesc is the grpc.ServiceDesc for Dex service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var Dex_ServiceDesc = grpc.ServiceDesc{
ServiceName: "api.Dex",
HandlerType: (*DexServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "CreateClient",
Handler: _Dex_CreateClient_Handler,
},
{
MethodName: "UpdateClient",
Handler: _Dex_UpdateClient_Handler,
},
{
MethodName: "DeleteClient",
Handler: _Dex_DeleteClient_Handler,
},
{
MethodName: "CreatePassword",
Handler: _Dex_CreatePassword_Handler,
},
{
MethodName: "UpdatePassword",
Handler: _Dex_UpdatePassword_Handler,
},
{
MethodName: "DeletePassword",
Handler: _Dex_DeletePassword_Handler,
},
{
MethodName: "ListPasswords",
Handler: _Dex_ListPasswords_Handler,
},
{
MethodName: "GetVersion",
Handler: _Dex_GetVersion_Handler,
},
{
MethodName: "ListRefresh",
Handler: _Dex_ListRefresh_Handler,
},
{
MethodName: "RevokeRefresh",
Handler: _Dex_RevokeRefresh_Handler,
},
{
MethodName: "VerifyPassword",
Handler: _Dex_VerifyPassword_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "api/v2/api.proto",
}

View file

@ -1,16 +0,0 @@
module github.com/dexidp/dex/api/v2
go 1.17
require (
google.golang.org/grpc v1.47.0
google.golang.org/protobuf v1.28.0
)
require (
github.com/golang/protobuf v1.5.2 // indirect
golang.org/x/net v0.0.0-20220607020251-c690dde0001d // indirect
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect
golang.org/x/text v0.3.7 // indirect
google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8 // indirect
)

View file

@ -1,141 +0,0 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20220607020251-c690dde0001d h1:4SFsTMi4UahlKoloni7L4eYzhFRifURQLw+yv0QDCx8=
golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8 h1:qRu95HZ148xXw+XeZ3dvqe85PxH4X8+jIo0iRPKcEnM=
google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8/go.mod h1:yKyY4AMRwFiC8yMMNaMi+RkCnjZJt9LoWuvhXjMs+To=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/grpc v1.47.0 h1:9n77onPX5F3qfFCqjy9dhn8PbNQsIKeVU04J9G7umt8=
google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View file

@ -5,27 +5,30 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"strconv"
"strings"
"github.com/Sirupsen/logrus"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"github.com/dexidp/dex/pkg/log" "github.com/coreos/dex/connector"
"github.com/dexidp/dex/server" "github.com/coreos/dex/connector/github"
"github.com/dexidp/dex/storage" "github.com/coreos/dex/connector/gitlab"
"github.com/dexidp/dex/storage/ent" "github.com/coreos/dex/connector/ldap"
"github.com/dexidp/dex/storage/etcd" "github.com/coreos/dex/connector/mock"
"github.com/dexidp/dex/storage/kubernetes" "github.com/coreos/dex/connector/oidc"
"github.com/dexidp/dex/storage/memory" "github.com/coreos/dex/connector/saml"
"github.com/dexidp/dex/storage/sql" "github.com/coreos/dex/server"
"github.com/coreos/dex/storage"
"github.com/coreos/dex/storage/kubernetes"
"github.com/coreos/dex/storage/memory"
"github.com/coreos/dex/storage/sql"
) )
// Config is the config format for the main application. // Config is the config format for the main application.
type Config struct { type Config struct {
Issuer string `json:"issuer"` Issuer string `json:"issuer"`
Storage Storage `json:"storage"` Storage Storage `json:"storage"`
Connectors []Connector `json:"connectors"`
Web Web `json:"web"` Web Web `json:"web"`
Telemetry Telemetry `json:"telemetry"`
OAuth2 OAuth2 `json:"oauth2"` OAuth2 OAuth2 `json:"oauth2"`
GRPC GRPC `json:"grpc"` GRPC GRPC `json:"grpc"`
Expiry Expiry `json:"expiry"` Expiry Expiry `json:"expiry"`
@ -33,10 +36,6 @@ type Config struct {
Frontend server.WebConfig `json:"frontend"` Frontend server.WebConfig `json:"frontend"`
// StaticConnectors are user defined connectors specified in the ConfigMap
// Write operations, like updating a connector, will fail.
StaticConnectors []Connector `json:"connectors"`
// StaticClients cause the server to use this list of clients rather than // StaticClients cause the server to use this list of clients rather than
// querying the storage. Write operations, like creating a client, will fail. // querying the storage. Write operations, like creating a client, will fail.
StaticClients []storage.Client `json:"staticClients"` StaticClients []storage.Client `json:"staticClients"`
@ -51,38 +50,6 @@ type Config struct {
StaticPasswords []password `json:"staticPasswords"` StaticPasswords []password `json:"staticPasswords"`
} }
// Validate the configuration
func (c Config) Validate() error {
// Fast checks. Perform these first for a more responsive CLI.
checks := []struct {
bad bool
errMsg string
}{
{c.Issuer == "", "no issuer specified in config file"},
{!c.EnablePasswordDB && len(c.StaticPasswords) != 0, "cannot specify static passwords without enabling password db"},
{c.Storage.Config == nil, "no storage supplied in config file"},
{c.Web.HTTP == "" && c.Web.HTTPS == "", "must supply a HTTP/HTTPS address to listen on"},
{c.Web.HTTPS != "" && c.Web.TLSCert == "", "no cert specified for HTTPS"},
{c.Web.HTTPS != "" && c.Web.TLSKey == "", "no private key specified for HTTPS"},
{c.GRPC.TLSCert != "" && c.GRPC.Addr == "", "no address specified for gRPC"},
{c.GRPC.TLSKey != "" && c.GRPC.Addr == "", "no address specified for gRPC"},
{(c.GRPC.TLSCert == "") != (c.GRPC.TLSKey == ""), "must specific both a gRPC TLS cert and key"},
{c.GRPC.TLSCert == "" && c.GRPC.TLSClientCA != "", "cannot specify gRPC TLS client CA without a gRPC TLS cert"},
}
var checkErrors []string
for _, check := range checks {
if check.bad {
checkErrors = append(checkErrors, check.errMsg)
}
}
if len(checkErrors) != 0 {
return fmt.Errorf("invalid Config:\n\t-\t%s", strings.Join(checkErrors, "\n\t-\t"))
}
return nil
}
type password storage.Password type password storage.Password
func (p *password) UnmarshalJSON(b []byte) error { func (p *password) UnmarshalJSON(b []byte) error {
@ -91,7 +58,6 @@ func (p *password) UnmarshalJSON(b []byte) error {
Username string `json:"username"` Username string `json:"username"`
UserID string `json:"userID"` UserID string `json:"userID"`
Hash string `json:"hash"` Hash string `json:"hash"`
HashFromEnv string `json:"hashFromEnv"`
} }
if err := json.Unmarshal(b, &data); err != nil { if err := json.Unmarshal(b, &data); err != nil {
return err return err
@ -101,9 +67,6 @@ func (p *password) UnmarshalJSON(b []byte) error {
Username: data.Username, Username: data.Username,
UserID: data.UserID, UserID: data.UserID,
}) })
if len(data.Hash) == 0 && len(data.HashFromEnv) > 0 {
data.Hash = os.Getenv(data.HashFromEnv)
}
if len(data.Hash) == 0 { if len(data.Hash) == 0 {
return fmt.Errorf("no password hash provided") return fmt.Errorf("no password hash provided")
} }
@ -133,10 +96,6 @@ type OAuth2 struct {
// If specified, do not prompt the user to approve client authorization. The // If specified, do not prompt the user to approve client authorization. The
// act of logging in implies authorization. // act of logging in implies authorization.
SkipApprovalScreen bool `json:"skipApprovalScreen"` SkipApprovalScreen bool `json:"skipApprovalScreen"`
// If specified, show the connector selection screen even if there's only one
AlwaysShowLoginScreen bool `json:"alwaysShowLoginScreen"`
// This is the connector that can be used for password grant
PasswordConnector string `json:"passwordConnector"`
} }
// Web is the config format for the HTTP server. // Web is the config format for the HTTP server.
@ -148,13 +107,6 @@ type Web struct {
AllowedOrigins []string `json:"allowedOrigins"` AllowedOrigins []string `json:"allowedOrigins"`
} }
// Telemetry is the config format for telemetry including the HTTP server config.
type Telemetry struct {
HTTP string `json:"http"`
// EnableProfiling makes profiling endpoints available via web interface host:port/debug/pprof/
EnableProfiling bool `json:"enableProfiling"`
}
// GRPC is the config for the gRPC API. // GRPC is the config for the gRPC API.
type GRPC struct { type GRPC struct {
// The port to listen on. // The port to listen on.
@ -162,7 +114,6 @@ type GRPC struct {
TLSCert string `json:"tlsCert"` TLSCert string `json:"tlsCert"`
TLSKey string `json:"tlsKey"` TLSKey string `json:"tlsKey"`
TLSClientCA string `json:"tlsClientCA"` TLSClientCA string `json:"tlsClientCA"`
Reflection bool `json:"reflection"`
} }
// Storage holds app's storage configuration. // Storage holds app's storage configuration.
@ -173,52 +124,14 @@ type Storage struct {
// StorageConfig is a configuration that can create a storage. // StorageConfig is a configuration that can create a storage.
type StorageConfig interface { type StorageConfig interface {
Open(logger log.Logger) (storage.Storage, error) Open(logrus.FieldLogger) (storage.Storage, error)
}
var (
_ StorageConfig = (*etcd.Etcd)(nil)
_ StorageConfig = (*kubernetes.Config)(nil)
_ StorageConfig = (*memory.Config)(nil)
_ StorageConfig = (*sql.SQLite3)(nil)
_ StorageConfig = (*sql.Postgres)(nil)
_ StorageConfig = (*sql.MySQL)(nil)
_ StorageConfig = (*ent.SQLite3)(nil)
_ StorageConfig = (*ent.Postgres)(nil)
_ StorageConfig = (*ent.MySQL)(nil)
)
func getORMBasedSQLStorage(normal, entBased StorageConfig) func() StorageConfig {
return func() StorageConfig {
switch os.Getenv("DEX_ENT_ENABLED") {
case "true", "yes":
return entBased
default:
return normal
}
}
} }
var storages = map[string]func() StorageConfig{ var storages = map[string]func() StorageConfig{
"etcd": func() StorageConfig { return new(etcd.Etcd) },
"kubernetes": func() StorageConfig { return new(kubernetes.Config) }, "kubernetes": func() StorageConfig { return new(kubernetes.Config) },
"memory": func() StorageConfig { return new(memory.Config) }, "memory": func() StorageConfig { return new(memory.Config) },
"sqlite3": getORMBasedSQLStorage(&sql.SQLite3{}, &ent.SQLite3{}), "sqlite3": func() StorageConfig { return new(sql.SQLite3) },
"postgres": getORMBasedSQLStorage(&sql.Postgres{}, &ent.Postgres{}), "postgres": func() StorageConfig { return new(sql.Postgres) },
"mysql": getORMBasedSQLStorage(&sql.MySQL{}, &ent.MySQL{}),
}
// isExpandEnvEnabled returns if os.ExpandEnv should be used for each storage and connector config.
// Disabling this feature avoids surprises e.g. if the LDAP bind password contains a dollar character.
// Returns false if the env variable "DEX_EXPAND_ENV" is a falsy string, e.g. "false".
// Returns true if the env variable is unset or a truthy string, e.g. "true", or can't be parsed as bool.
func isExpandEnvEnabled() bool {
enabled, err := strconv.ParseBool(os.Getenv("DEX_EXPAND_ENV"))
if err != nil {
// Unset, empty string or can't be parsed as bool: Default = true.
return true
}
return enabled
} }
// UnmarshalJSON allows Storage to implement the unmarshaler interface to // UnmarshalJSON allows Storage to implement the unmarshaler interface to
@ -238,13 +151,9 @@ func (s *Storage) UnmarshalJSON(b []byte) error {
storageConfig := f() storageConfig := f()
if len(store.Config) != 0 { if len(store.Config) != 0 {
data := []byte(store.Config) data := []byte(os.ExpandEnv(string(store.Config)))
if isExpandEnvEnabled() {
// Caution, we're expanding in the raw JSON/YAML source. This may not be what the admin expects.
data = []byte(os.ExpandEnv(string(store.Config)))
}
if err := json.Unmarshal(data, storageConfig); err != nil { if err := json.Unmarshal(data, storageConfig); err != nil {
return fmt.Errorf("parse storage config: %v", err) return fmt.Errorf("parse storace config: %v", err)
} }
} }
*s = Storage{ *s = Storage{
@ -261,7 +170,24 @@ type Connector struct {
Name string `json:"name"` Name string `json:"name"`
ID string `json:"id"` ID string `json:"id"`
Config server.ConnectorConfig `json:"config"` Config ConnectorConfig `json:"config"`
}
// ConnectorConfig is a configuration that can open a connector.
type ConnectorConfig interface {
Open(logrus.FieldLogger) (connector.Connector, error)
}
var connectors = map[string]func() ConnectorConfig{
"mockCallback": func() ConnectorConfig { return new(mock.CallbackConfig) },
"mockPassword": func() ConnectorConfig { return new(mock.PasswordConfig) },
"ldap": func() ConnectorConfig { return new(ldap.Config) },
"github": func() ConnectorConfig { return new(github.Config) },
"gitlab": func() ConnectorConfig { return new(gitlab.Config) },
"oidc": func() ConnectorConfig { return new(oidc.Config) },
"saml": func() ConnectorConfig { return new(saml.Config) },
// Keep around for backwards compatibility.
"samlExperimental": func() ConnectorConfig { return new(saml.Config) },
} }
// UnmarshalJSON allows Connector to implement the unmarshaler interface to // UnmarshalJSON allows Connector to implement the unmarshaler interface to
@ -277,18 +203,14 @@ func (c *Connector) UnmarshalJSON(b []byte) error {
if err := json.Unmarshal(b, &conn); err != nil { if err := json.Unmarshal(b, &conn); err != nil {
return fmt.Errorf("parse connector: %v", err) return fmt.Errorf("parse connector: %v", err)
} }
f, ok := server.ConnectorsConfig[conn.Type] f, ok := connectors[conn.Type]
if !ok { if !ok {
return fmt.Errorf("unknown connector type %q", conn.Type) return fmt.Errorf("unknown connector type %q", conn.Type)
} }
connConfig := f() connConfig := f()
if len(conn.Config) != 0 { if len(conn.Config) != 0 {
data := []byte(conn.Config) data := []byte(os.ExpandEnv(string(conn.Config)))
if isExpandEnvEnabled() {
// Caution, we're expanding in the raw JSON/YAML source. This may not be what the admin expects.
data = []byte(os.ExpandEnv(string(conn.Config)))
}
if err := json.Unmarshal(data, connConfig); err != nil { if err := json.Unmarshal(data, connConfig); err != nil {
return fmt.Errorf("parse connector config: %v", err) return fmt.Errorf("parse connector config: %v", err)
} }
@ -302,21 +224,6 @@ func (c *Connector) UnmarshalJSON(b []byte) error {
return nil return nil
} }
// ToStorageConnector converts an object to storage connector type.
func ToStorageConnector(c Connector) (storage.Connector, error) {
data, err := json.Marshal(c.Config)
if err != nil {
return storage.Connector{}, fmt.Errorf("failed to marshal connector config: %v", err)
}
return storage.Connector{
ID: c.ID,
Type: c.Type,
Name: c.Name,
Config: data,
}, nil
}
// Expiry holds configuration for the validity period of components. // Expiry holds configuration for the validity period of components.
type Expiry struct { type Expiry struct {
// SigningKeys defines the duration of time after which the SigningKeys will be rotated. // SigningKeys defines the duration of time after which the SigningKeys will be rotated.
@ -324,15 +231,6 @@ type Expiry struct {
// IdTokens defines the duration of time for which the IdTokens will be valid. // IdTokens defines the duration of time for which the IdTokens will be valid.
IDTokens string `json:"idTokens"` IDTokens string `json:"idTokens"`
// AuthRequests defines the duration of time for which the AuthRequests will be valid.
AuthRequests string `json:"authRequests"`
// DeviceRequests defines the duration of time for which the DeviceRequests will be valid.
DeviceRequests string `json:"deviceRequests"`
// RefreshTokens defines refresh tokens expiry policy
RefreshTokens RefreshToken `json:"refreshTokens"`
} }
// Logger holds configuration required to customize logging for dex. // Logger holds configuration required to customize logging for dex.
@ -343,10 +241,3 @@ type Logger struct {
// Format specifies the format to be used for logging. // Format specifies the format to be used for logging.
Format string `json:"format"` Format string `json:"format"`
} }
type RefreshToken struct {
DisableRotation bool `json:"disableRotation"`
ReuseInterval string `json:"reuseInterval"`
AbsoluteLifetime string `json:"absoluteLifetime"`
ValidIfNotUsedFor string `json:"validIfNotUsedFor"`
}

View file

@ -1,83 +1,28 @@
package main package main
import ( import (
"os"
"testing" "testing"
"github.com/coreos/dex/connector/mock"
"github.com/coreos/dex/connector/oidc"
"github.com/coreos/dex/storage"
"github.com/coreos/dex/storage/sql"
"github.com/ghodss/yaml" "github.com/ghodss/yaml"
"github.com/kylelemons/godebug/pretty" "github.com/kylelemons/godebug/pretty"
"github.com/dexidp/dex/connector/mock"
"github.com/dexidp/dex/connector/oidc"
"github.com/dexidp/dex/server"
"github.com/dexidp/dex/storage"
"github.com/dexidp/dex/storage/sql"
) )
var _ = yaml.YAMLToJSON var _ = yaml.YAMLToJSON
func TestValidConfiguration(t *testing.T) {
configuration := Config{
Issuer: "http://127.0.0.1:5556/dex",
Storage: Storage{
Type: "sqlite3",
Config: &sql.SQLite3{
File: "examples/dex.db",
},
},
Web: Web{
HTTP: "127.0.0.1:5556",
},
StaticConnectors: []Connector{
{
Type: "mockCallback",
ID: "mock",
Name: "Example",
Config: &mock.CallbackConfig{},
},
},
}
if err := configuration.Validate(); err != nil {
t.Fatalf("this configuration should have been valid: %v", err)
}
}
func TestInvalidConfiguration(t *testing.T) {
configuration := Config{}
err := configuration.Validate()
if err == nil {
t.Fatal("this configuration should be invalid")
}
got := err.Error()
wanted := `invalid Config:
- no issuer specified in config file
- no storage supplied in config file
- must supply a HTTP/HTTPS address to listen on`
if got != wanted {
t.Fatalf("Expected error message to be %q, got %q", wanted, got)
}
}
func TestUnmarshalConfig(t *testing.T) { func TestUnmarshalConfig(t *testing.T) {
rawConfig := []byte(` rawConfig := []byte(`
issuer: http://127.0.0.1:5556/dex issuer: http://127.0.0.1:5556/dex
storage: storage:
type: postgres type: sqlite3
config: config:
host: 10.0.0.1 file: examples/dex.db
port: 65432
maxOpenConns: 5
maxIdleConns: 3
connMaxLifetime: 30
connectionTimeout: 3
web: web:
http: 127.0.0.1:5556 http: 127.0.0.1:5556
frontend:
dir: ./web
extra:
foo: bar
staticClients: staticClients:
- id: example-app - id: example-app
redirectURIs: redirectURIs:
@ -85,9 +30,6 @@ staticClients:
name: 'Example App' name: 'Example App'
secret: ZXhhbXBsZS1hcHAtc2VjcmV0 secret: ZXhhbXBsZS1hcHAtc2VjcmV0
oauth2:
alwaysShowLoginScreen: true
connectors: connectors:
- type: mockCallback - type: mockCallback
id: mock id: mock
@ -115,10 +57,8 @@ staticPasswords:
userID: "41331323-6f44-45e6-b3b9-2c4b60c02be5" userID: "41331323-6f44-45e6-b3b9-2c4b60c02be5"
expiry: expiry:
signingKeys: "7h" signingKeys: "6h"
idTokens: "25h" idTokens: "24h"
authRequests: "25h"
deviceRequests: "10m"
logger: logger:
level: "debug" level: "debug"
@ -128,27 +68,14 @@ logger:
want := Config{ want := Config{
Issuer: "http://127.0.0.1:5556/dex", Issuer: "http://127.0.0.1:5556/dex",
Storage: Storage{ Storage: Storage{
Type: "postgres", Type: "sqlite3",
Config: &sql.Postgres{ Config: &sql.SQLite3{
NetworkDB: sql.NetworkDB{ File: "examples/dex.db",
Host: "10.0.0.1",
Port: 65432,
MaxOpenConns: 5,
MaxIdleConns: 3,
ConnMaxLifetime: 30,
ConnectionTimeout: 3,
},
}, },
}, },
Web: Web{ Web: Web{
HTTP: "127.0.0.1:5556", HTTP: "127.0.0.1:5556",
}, },
Frontend: server.WebConfig{
Dir: "./web",
Extra: map[string]string{
"foo": "bar",
},
},
StaticClients: []storage.Client{ StaticClients: []storage.Client{
{ {
ID: "example-app", ID: "example-app",
@ -159,10 +86,7 @@ logger:
}, },
}, },
}, },
OAuth2: OAuth2{ Connectors: []Connector{
AlwaysShowLoginScreen: true,
},
StaticConnectors: []Connector{
{ {
Type: "mockCallback", Type: "mockCallback",
ID: "mock", ID: "mock",
@ -197,217 +121,8 @@ logger:
}, },
}, },
Expiry: Expiry{ Expiry: Expiry{
SigningKeys: "7h", SigningKeys: "6h",
IDTokens: "25h", IDTokens: "24h",
AuthRequests: "25h",
DeviceRequests: "10m",
},
Logger: Logger{
Level: "debug",
Format: "json",
},
}
var c Config
if err := yaml.Unmarshal(rawConfig, &c); err != nil {
t.Fatalf("failed to decode config: %v", err)
}
if diff := pretty.Compare(c, want); diff != "" {
t.Errorf("got!=want: %s", diff)
}
}
func TestUnmarshalConfigWithEnvNoExpand(t *testing.T) {
// If the env variable DEX_EXPAND_ENV is set and has a "falsy" value, os.ExpandEnv is disabled.
// ParseBool: "It accepts 1, t, T, TRUE, true, True, 0, f, F, FALSE, false, False."
checkUnmarshalConfigWithEnv(t, "0", false)
checkUnmarshalConfigWithEnv(t, "f", false)
checkUnmarshalConfigWithEnv(t, "F", false)
checkUnmarshalConfigWithEnv(t, "FALSE", false)
checkUnmarshalConfigWithEnv(t, "false", false)
checkUnmarshalConfigWithEnv(t, "False", false)
os.Unsetenv("DEX_EXPAND_ENV")
}
func TestUnmarshalConfigWithEnvExpand(t *testing.T) {
// If the env variable DEX_EXPAND_ENV is unset or has a "truthy" or unknown value, os.ExpandEnv is enabled.
// ParseBool: "It accepts 1, t, T, TRUE, true, True, 0, f, F, FALSE, false, False."
checkUnmarshalConfigWithEnv(t, "1", true)
checkUnmarshalConfigWithEnv(t, "t", true)
checkUnmarshalConfigWithEnv(t, "T", true)
checkUnmarshalConfigWithEnv(t, "TRUE", true)
checkUnmarshalConfigWithEnv(t, "true", true)
checkUnmarshalConfigWithEnv(t, "True", true)
// Values that can't be parsed as bool:
checkUnmarshalConfigWithEnv(t, "UNSET", true)
checkUnmarshalConfigWithEnv(t, "", true)
checkUnmarshalConfigWithEnv(t, "whatever - true is default", true)
os.Unsetenv("DEX_EXPAND_ENV")
}
func checkUnmarshalConfigWithEnv(t *testing.T, dexExpandEnv string, wantExpandEnv bool) {
// For hashFromEnv:
os.Setenv("DEX_FOO_USER_PASSWORD", "$2a$10$33EMT0cVYVlPy6WAMCLsceLYjWhuHpbz5yuZxu/GAFj03J9Lytjuy")
// For os.ExpandEnv ($VAR -> value_of_VAR):
os.Setenv("DEX_FOO_POSTGRES_HOST", "10.0.0.1")
os.Setenv("DEX_FOO_OIDC_CLIENT_SECRET", "bar")
if dexExpandEnv != "UNSET" {
os.Setenv("DEX_EXPAND_ENV", dexExpandEnv)
} else {
os.Unsetenv("DEX_EXPAND_ENV")
}
rawConfig := []byte(`
issuer: http://127.0.0.1:5556/dex
storage:
type: postgres
config:
# Env variables are expanded in raw YAML source.
# Single quotes work fine, as long as the env variable doesn't contain any.
host: '$DEX_FOO_POSTGRES_HOST'
port: 65432
maxOpenConns: 5
maxIdleConns: 3
connMaxLifetime: 30
connectionTimeout: 3
web:
http: 127.0.0.1:5556
frontend:
dir: ./web
extra:
foo: bar
staticClients:
- id: example-app
redirectURIs:
- 'http://127.0.0.1:5555/callback'
name: 'Example App'
secret: ZXhhbXBsZS1hcHAtc2VjcmV0
oauth2:
alwaysShowLoginScreen: true
connectors:
- type: mockCallback
id: mock
name: Example
- type: oidc
id: google
name: Google
config:
issuer: https://accounts.google.com
clientID: foo
# Env variables are expanded in raw YAML source.
# Single quotes work fine, as long as the env variable doesn't contain any.
clientSecret: '$DEX_FOO_OIDC_CLIENT_SECRET'
redirectURI: http://127.0.0.1:5556/dex/callback/google
enablePasswordDB: true
staticPasswords:
- email: "admin@example.com"
# bcrypt hash of the string "password"
hash: "$2a$10$33EMT0cVYVlPy6WAMCLsceLYjWhuHpbz5yuZxu/GAFj03J9Lytjuy"
username: "admin"
userID: "08a8684b-db88-4b73-90a9-3cd1661f5466"
- email: "foo@example.com"
hashFromEnv: "DEX_FOO_USER_PASSWORD"
username: "foo"
userID: "41331323-6f44-45e6-b3b9-2c4b60c02be5"
expiry:
signingKeys: "7h"
idTokens: "25h"
authRequests: "25h"
logger:
level: "debug"
format: "json"
`)
// This is not a valid hostname. It's only used to check whether os.ExpandEnv was applied or not.
wantPostgresHost := "$DEX_FOO_POSTGRES_HOST"
wantOidcClientSecret := "$DEX_FOO_OIDC_CLIENT_SECRET"
if wantExpandEnv {
wantPostgresHost = "10.0.0.1"
wantOidcClientSecret = "bar"
}
want := Config{
Issuer: "http://127.0.0.1:5556/dex",
Storage: Storage{
Type: "postgres",
Config: &sql.Postgres{
NetworkDB: sql.NetworkDB{
Host: wantPostgresHost,
Port: 65432,
MaxOpenConns: 5,
MaxIdleConns: 3,
ConnMaxLifetime: 30,
ConnectionTimeout: 3,
},
},
},
Web: Web{
HTTP: "127.0.0.1:5556",
},
Frontend: server.WebConfig{
Dir: "./web",
Extra: map[string]string{
"foo": "bar",
},
},
StaticClients: []storage.Client{
{
ID: "example-app",
Secret: "ZXhhbXBsZS1hcHAtc2VjcmV0",
Name: "Example App",
RedirectURIs: []string{
"http://127.0.0.1:5555/callback",
},
},
},
OAuth2: OAuth2{
AlwaysShowLoginScreen: true,
},
StaticConnectors: []Connector{
{
Type: "mockCallback",
ID: "mock",
Name: "Example",
Config: &mock.CallbackConfig{},
},
{
Type: "oidc",
ID: "google",
Name: "Google",
Config: &oidc.Config{
Issuer: "https://accounts.google.com",
ClientID: "foo",
ClientSecret: wantOidcClientSecret,
RedirectURI: "http://127.0.0.1:5556/dex/callback/google",
},
},
},
EnablePasswordDB: true,
StaticPasswords: []password{
{
Email: "admin@example.com",
Hash: []byte("$2a$10$33EMT0cVYVlPy6WAMCLsceLYjWhuHpbz5yuZxu/GAFj03J9Lytjuy"),
Username: "admin",
UserID: "08a8684b-db88-4b73-90a9-3cd1661f5466",
},
{
Email: "foo@example.com",
Hash: []byte("$2a$10$33EMT0cVYVlPy6WAMCLsceLYjWhuHpbz5yuZxu/GAFj03J9Lytjuy"),
Username: "foo",
UserID: "41331323-6f44-45e6-b3b9-2c4b60c02be5",
},
},
Expiry: Expiry{
SigningKeys: "7h",
IDTokens: "25h",
AuthRequests: "25h",
}, },
Logger: Logger{ Logger: Logger{
Level: "debug", Level: "debug",
@ -422,4 +137,5 @@ logger:
if diff := pretty.Compare(c, want); diff != "" { if diff := pretty.Compare(c, want); diff != "" {
t.Errorf("got!=want: %s", diff) t.Errorf("got!=want: %s", diff)
} }
} }

View file

@ -6,78 +6,51 @@ import (
"crypto/x509" "crypto/x509"
"errors" "errors"
"fmt" "fmt"
"io/ioutil"
"net" "net"
"net/http" "net/http"
"net/http/pprof"
"os" "os"
"runtime"
"strings" "strings"
"syscall"
"time" "time"
gosundheit "github.com/AppsFlyer/go-sundheit" "github.com/Sirupsen/logrus"
"github.com/AppsFlyer/go-sundheit/checks"
gosundheithttp "github.com/AppsFlyer/go-sundheit/http"
"github.com/ghodss/yaml" "github.com/ghodss/yaml"
grpcprometheus "github.com/grpc-ecosystem/go-grpc-prometheus"
"github.com/oklog/run"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials"
"google.golang.org/grpc/reflection"
"github.com/dexidp/dex/api/v2" "github.com/coreos/dex/api"
"github.com/dexidp/dex/pkg/log" "github.com/coreos/dex/server"
"github.com/dexidp/dex/server" "github.com/coreos/dex/storage"
"github.com/dexidp/dex/storage"
) )
type serveOptions struct {
// Config file path
config string
// Flags
webHTTPAddr string
webHTTPSAddr string
telemetryAddr string
grpcAddr string
}
func commandServe() *cobra.Command { func commandServe() *cobra.Command {
options := serveOptions{} return &cobra.Command{
Use: "serve [ config file ]",
cmd := &cobra.Command{ Short: "Connect to the storage and begin serving requests.",
Use: "serve [flags] [config file]", Long: ``,
Short: "Launch Dex",
Example: "dex serve config.yaml", Example: "dex serve config.yaml",
Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) {
RunE: func(cmd *cobra.Command, args []string) error { if err := serve(cmd, args); err != nil {
cmd.SilenceUsage = true fmt.Fprintln(os.Stderr, err)
cmd.SilenceErrors = true os.Exit(2)
}
options.config = args[0]
return runServe(options)
}, },
} }
flags := cmd.Flags()
flags.StringVar(&options.webHTTPAddr, "web-http-addr", "", "Web HTTP address")
flags.StringVar(&options.webHTTPSAddr, "web-https-addr", "", "Web HTTPS address")
flags.StringVar(&options.telemetryAddr, "telemetry-addr", "", "Telemetry address")
flags.StringVar(&options.grpcAddr, "grpc-addr", "", "gRPC API address")
return cmd
} }
func runServe(options serveOptions) error { func serve(cmd *cobra.Command, args []string) error {
configFile := options.config switch len(args) {
configData, err := os.ReadFile(configFile) default:
return errors.New("surplus arguments")
case 0:
// TODO(ericchiang): Consider having a default config file location.
return errors.New("no arguments provided")
case 1:
}
configFile := args[0]
configData, err := ioutil.ReadFile(configFile)
if err != nil { if err != nil {
return fmt.Errorf("failed to read config file %s: %v", configFile, err) return fmt.Errorf("failed to read config file %s: %v", configFile, err)
} }
@ -87,130 +60,111 @@ func runServe(options serveOptions) error {
return fmt.Errorf("error parse config file %s: %v", configFile, err) return fmt.Errorf("error parse config file %s: %v", configFile, err)
} }
applyConfigOverrides(options, &c)
logger, err := newLogger(c.Logger.Level, c.Logger.Format) logger, err := newLogger(c.Logger.Level, c.Logger.Format)
if err != nil { if err != nil {
return fmt.Errorf("invalid config: %v", err) return fmt.Errorf("invalid config: %v", err)
} }
logger.Infof(
"Dex Version: %s, Go Version: %s, Go OS/ARCH: %s %s",
version,
runtime.Version(),
runtime.GOOS,
runtime.GOARCH,
)
if c.Logger.Level != "" { if c.Logger.Level != "" {
logger.Infof("config using log level: %s", c.Logger.Level) logger.Infof("config using log level: %s", c.Logger.Level)
} }
if err := c.Validate(); err != nil {
return err // Fast checks. Perform these first for a more responsive CLI.
checks := []struct {
bad bool
errMsg string
}{
{c.Issuer == "", "no issuer specified in config file"},
{len(c.Connectors) == 0 && !c.EnablePasswordDB, "no connectors supplied in config file"},
{!c.EnablePasswordDB && len(c.StaticPasswords) != 0, "cannot specify static passwords without enabling password db"},
{c.Storage.Config == nil, "no storage suppied in config file"},
{c.Web.HTTP == "" && c.Web.HTTPS == "", "must supply a HTTP/HTTPS address to listen on"},
{c.Web.HTTPS != "" && c.Web.TLSCert == "", "no cert specified for HTTPS"},
{c.Web.HTTPS != "" && c.Web.TLSKey == "", "no private key specified for HTTPS"},
{c.GRPC.TLSCert != "" && c.GRPC.Addr == "", "no address specified for gRPC"},
{c.GRPC.TLSKey != "" && c.GRPC.Addr == "", "no address specified for gRPC"},
{(c.GRPC.TLSCert == "") != (c.GRPC.TLSKey == ""), "must specific both a gRPC TLS cert and key"},
{c.GRPC.TLSCert == "" && c.GRPC.TLSClientCA != "", "cannot specify gRPC TLS client CA without a gRPC TLS cert"},
}
for _, check := range checks {
if check.bad {
return fmt.Errorf("invalid config: %s", check.errMsg)
}
} }
logger.Infof("config issuer: %s", c.Issuer) logger.Infof("config issuer: %s", c.Issuer)
prometheusRegistry := prometheus.NewRegistry()
err = prometheusRegistry.Register(collectors.NewGoCollector())
if err != nil {
return fmt.Errorf("failed to register Go runtime metrics: %v", err)
}
err = prometheusRegistry.Register(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
if err != nil {
return fmt.Errorf("failed to register process metrics: %v", err)
}
grpcMetrics := grpcprometheus.NewServerMetrics()
err = prometheusRegistry.Register(grpcMetrics)
if err != nil {
return fmt.Errorf("failed to register gRPC server metrics: %v", err)
}
var grpcOptions []grpc.ServerOption var grpcOptions []grpc.ServerOption
allowedTLSCiphers := []uint16{
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
}
if c.GRPC.TLSCert != "" { if c.GRPC.TLSCert != "" {
if c.GRPC.TLSClientCA != "" {
// Parse certificates from certificate file and key file for server. // Parse certificates from certificate file and key file for server.
cert, err := tls.LoadX509KeyPair(c.GRPC.TLSCert, c.GRPC.TLSKey) cert, err := tls.LoadX509KeyPair(c.GRPC.TLSCert, c.GRPC.TLSKey)
if err != nil { if err != nil {
return fmt.Errorf("invalid config: error parsing gRPC certificate file: %v", err) return fmt.Errorf("invalid config: error parsing gRPC certificate file: %v", err)
} }
tlsConfig := tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
CipherSuites: allowedTLSCiphers,
PreferServerCipherSuites: true,
}
if c.GRPC.TLSClientCA != "" {
// Parse certificates from client CA file to a new CertPool. // Parse certificates from client CA file to a new CertPool.
cPool := x509.NewCertPool() cPool := x509.NewCertPool()
clientCert, err := os.ReadFile(c.GRPC.TLSClientCA) clientCert, err := ioutil.ReadFile(c.GRPC.TLSClientCA)
if err != nil { if err != nil {
return fmt.Errorf("invalid config: reading from client CA file: %v", err) return fmt.Errorf("invalid config: reading from client CA file: %v", err)
} }
if !cPool.AppendCertsFromPEM(clientCert) { if cPool.AppendCertsFromPEM(clientCert) != true {
return errors.New("invalid config: failed to parse client CA") return errors.New("invalid config: failed to parse client CA")
} }
tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert tlsConfig := tls.Config{
tlsConfig.ClientCAs = cPool Certificates: []tls.Certificate{cert},
ClientAuth: tls.RequireAndVerifyClientCert,
// Only add metrics if client auth is enabled ClientCAs: cPool,
grpcOptions = append(grpcOptions, }
grpc.StreamInterceptor(grpcMetrics.StreamServerInterceptor()), grpcOptions = append(grpcOptions, grpc.Creds(credentials.NewTLS(&tlsConfig)))
grpc.UnaryInterceptor(grpcMetrics.UnaryServerInterceptor()), } else {
) opt, err := credentials.NewServerTLSFromFile(c.GRPC.TLSCert, c.GRPC.TLSKey)
if err != nil {
return fmt.Errorf("invalid config: load grpc certs: %v", err)
}
grpcOptions = append(grpcOptions, grpc.Creds(opt))
}
} }
grpcOptions = append(grpcOptions, grpc.Creds(credentials.NewTLS(&tlsConfig))) connectors := make([]server.Connector, len(c.Connectors))
for i, conn := range c.Connectors {
if conn.ID == "" {
return fmt.Errorf("invalid config: no ID field for connector %d", i)
}
if conn.Config == nil {
return fmt.Errorf("invalid config: no config field for connector %q", conn.ID)
}
if conn.Name == "" {
return fmt.Errorf("invalid config: no Name field for connector %q", conn.ID)
}
logger.Infof("config connector: %s", conn.ID)
connectorLogger := logger.WithField("connector", conn.Name)
c, err := conn.Config.Open(connectorLogger)
if err != nil {
return fmt.Errorf("failed to create connector %s: %v", conn.ID, err)
}
connectors[i] = server.Connector{
ID: conn.ID,
DisplayName: conn.Name,
Connector: c,
}
}
if c.EnablePasswordDB {
logger.Infof("config connector: local passwords enabled")
} }
s, err := c.Storage.Config.Open(logger) s, err := c.Storage.Config.Open(logger)
if err != nil { if err != nil {
return fmt.Errorf("failed to initialize storage: %v", err) return fmt.Errorf("failed to initialize storage: %v", err)
} }
defer s.Close()
logger.Infof("config storage: %s", c.Storage.Type) logger.Infof("config storage: %s", c.Storage.Type)
if len(c.StaticClients) > 0 { if len(c.StaticClients) > 0 {
for i, client := range c.StaticClients { for _, client := range c.StaticClients {
if client.Name == "" { logger.Infof("config static client: %s", client.ID)
return fmt.Errorf("invalid config: Name field is required for a client")
}
if client.ID == "" && client.IDEnv == "" {
return fmt.Errorf("invalid config: ID or IDEnv field is required for a client")
}
if client.IDEnv != "" {
if client.ID != "" {
return fmt.Errorf("invalid config: ID and IDEnv fields are exclusive for client %q", client.ID)
}
c.StaticClients[i].ID = os.Getenv(client.IDEnv)
}
if client.Secret == "" && client.SecretEnv == "" && !client.Public {
return fmt.Errorf("invalid config: Secret or SecretEnv field is required for client %q", client.ID)
}
if client.SecretEnv != "" {
if client.Secret != "" {
return fmt.Errorf("invalid config: Secret and SecretEnv fields are exclusive for client %q", client.ID)
}
c.StaticClients[i].Secret = os.Getenv(client.SecretEnv)
}
logger.Infof("config static client: %s", client.Name)
} }
s = storage.WithStaticClients(s, c.StaticClients) s = storage.WithStaticClients(s, c.StaticClients)
} }
@ -219,47 +173,15 @@ func runServe(options serveOptions) error {
for i, p := range c.StaticPasswords { for i, p := range c.StaticPasswords {
passwords[i] = storage.Password(p) passwords[i] = storage.Password(p)
} }
s = storage.WithStaticPasswords(s, passwords, logger) s = storage.WithStaticPasswords(s, passwords)
} }
storageConnectors := make([]storage.Connector, len(c.StaticConnectors))
for i, c := range c.StaticConnectors {
if c.ID == "" || c.Name == "" || c.Type == "" {
return fmt.Errorf("invalid config: ID, Type and Name fields are required for a connector")
}
if c.Config == nil {
return fmt.Errorf("invalid config: no config field for connector %q", c.ID)
}
logger.Infof("config connector: %s", c.ID)
// convert to a storage connector object
conn, err := ToStorageConnector(c)
if err != nil {
return fmt.Errorf("failed to initialize storage connectors: %v", err)
}
storageConnectors[i] = conn
}
if c.EnablePasswordDB {
storageConnectors = append(storageConnectors, storage.Connector{
ID: server.LocalConnector,
Name: "Email",
Type: server.LocalConnector,
})
logger.Infof("config connector: local passwords enabled")
}
s = storage.WithStaticConnectors(s, storageConnectors)
if len(c.OAuth2.ResponseTypes) > 0 { if len(c.OAuth2.ResponseTypes) > 0 {
logger.Infof("config response types accepted: %s", c.OAuth2.ResponseTypes) logger.Infof("config response types accepted: %s", c.OAuth2.ResponseTypes)
} }
if c.OAuth2.SkipApprovalScreen { if c.OAuth2.SkipApprovalScreen {
logger.Infof("config skipping approval screen") logger.Infof("config skipping approval screen")
} }
if c.OAuth2.PasswordConnector != "" {
logger.Infof("config using password grant connector: %s", c.OAuth2.PasswordConnector)
}
if len(c.Web.AllowedOrigins) > 0 { if len(c.Web.AllowedOrigins) > 0 {
logger.Infof("config allowed origins: %s", c.Web.AllowedOrigins) logger.Infof("config allowed origins: %s", c.Web.AllowedOrigins)
} }
@ -267,21 +189,17 @@ func runServe(options serveOptions) error {
// explicitly convert to UTC. // explicitly convert to UTC.
now := func() time.Time { return time.Now().UTC() } now := func() time.Time { return time.Now().UTC() }
healthChecker := gosundheit.New()
serverConfig := server.Config{ serverConfig := server.Config{
SupportedResponseTypes: c.OAuth2.ResponseTypes, SupportedResponseTypes: c.OAuth2.ResponseTypes,
SkipApprovalScreen: c.OAuth2.SkipApprovalScreen, SkipApprovalScreen: c.OAuth2.SkipApprovalScreen,
AlwaysShowLoginScreen: c.OAuth2.AlwaysShowLoginScreen,
PasswordConnector: c.OAuth2.PasswordConnector,
AllowedOrigins: c.Web.AllowedOrigins, AllowedOrigins: c.Web.AllowedOrigins,
Issuer: c.Issuer, Issuer: c.Issuer,
Connectors: connectors,
Storage: s, Storage: s,
Web: c.Frontend, Web: c.Frontend,
EnablePasswordDB: c.EnablePasswordDB,
Logger: logger, Logger: logger,
Now: now, Now: now,
PrometheusRegistry: prometheusRegistry,
HealthChecker: healthChecker,
} }
if c.Expiry.SigningKeys != "" { if c.Expiry.SigningKeys != "" {
signingKeys, err := time.ParseDuration(c.Expiry.SigningKeys) signingKeys, err := time.ParseDuration(c.Expiry.SigningKeys)
@ -299,195 +217,44 @@ func runServe(options serveOptions) error {
logger.Infof("config id tokens valid for: %v", idTokens) logger.Infof("config id tokens valid for: %v", idTokens)
serverConfig.IDTokensValidFor = idTokens serverConfig.IDTokensValidFor = idTokens
} }
if c.Expiry.AuthRequests != "" {
authRequests, err := time.ParseDuration(c.Expiry.AuthRequests)
if err != nil {
return fmt.Errorf("invalid config value %q for auth request expiry: %v", c.Expiry.AuthRequests, err)
}
logger.Infof("config auth requests valid for: %v", authRequests)
serverConfig.AuthRequestsValidFor = authRequests
}
if c.Expiry.DeviceRequests != "" {
deviceRequests, err := time.ParseDuration(c.Expiry.DeviceRequests)
if err != nil {
return fmt.Errorf("invalid config value %q for device request expiry: %v", c.Expiry.AuthRequests, err)
}
logger.Infof("config device requests valid for: %v", deviceRequests)
serverConfig.DeviceRequestsValidFor = deviceRequests
}
refreshTokenPolicy, err := server.NewRefreshTokenPolicy(
logger,
c.Expiry.RefreshTokens.DisableRotation,
c.Expiry.RefreshTokens.ValidIfNotUsedFor,
c.Expiry.RefreshTokens.AbsoluteLifetime,
c.Expiry.RefreshTokens.ReuseInterval,
)
if err != nil {
return fmt.Errorf("invalid refresh token expiration policy config: %v", err)
}
serverConfig.RefreshTokenPolicy = refreshTokenPolicy
serv, err := server.NewServer(context.Background(), serverConfig) serv, err := server.NewServer(context.Background(), serverConfig)
if err != nil { if err != nil {
return fmt.Errorf("failed to initialize server: %v", err) return fmt.Errorf("failed to initialize server: %v", err)
} }
telemetryRouter := http.NewServeMux() errc := make(chan error, 3)
telemetryRouter.Handle("/metrics", promhttp.HandlerFor(prometheusRegistry, promhttp.HandlerOpts{}))
// Configure health checker
{
handler := gosundheithttp.HandleHealthJSON(healthChecker)
telemetryRouter.Handle("/healthz", handler)
// Kubernetes style health checks
telemetryRouter.HandleFunc("/healthz/live", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("ok"))
})
telemetryRouter.Handle("/healthz/ready", handler)
}
healthChecker.RegisterCheck(
&checks.CustomCheck{
CheckName: "storage",
CheckFunc: storage.NewCustomHealthCheckFunc(serverConfig.Storage, serverConfig.Now),
},
gosundheit.ExecutionPeriod(15*time.Second),
gosundheit.InitiallyPassing(true),
)
var group run.Group
// Set up telemetry server
if c.Telemetry.HTTP != "" {
const name = "telemetry"
logger.Infof("listening (%s) on %s", name, c.Telemetry.HTTP)
l, err := net.Listen("tcp", c.Telemetry.HTTP)
if err != nil {
return fmt.Errorf("listening (%s) on %s: %v", name, c.Telemetry.HTTP, err)
}
if c.Telemetry.EnableProfiling {
pprofHandler(telemetryRouter)
}
server := &http.Server{
Handler: telemetryRouter,
}
defer server.Close()
group.Add(func() error {
return server.Serve(l)
}, func(err error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
logger.Debugf("starting graceful shutdown (%s)", name)
if err := server.Shutdown(ctx); err != nil {
logger.Errorf("graceful shutdown (%s): %v", name, err)
}
})
}
// Set up http server
if c.Web.HTTP != "" { if c.Web.HTTP != "" {
const name = "http" logger.Infof("listening (http) on %s", c.Web.HTTP)
go func() {
logger.Infof("listening (%s) on %s", name, c.Web.HTTP) err := http.ListenAndServe(c.Web.HTTP, serv)
errc <- fmt.Errorf("listening on %s failed: %v", c.Web.HTTP, err)
l, err := net.Listen("tcp", c.Web.HTTP) }()
if err != nil {
return fmt.Errorf("listening (%s) on %s: %v", name, c.Web.HTTP, err)
} }
server := &http.Server{
Handler: serv,
}
defer server.Close()
group.Add(func() error {
return server.Serve(l)
}, func(err error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
logger.Debugf("starting graceful shutdown (%s)", name)
if err := server.Shutdown(ctx); err != nil {
logger.Errorf("graceful shutdown (%s): %v", name, err)
}
})
}
// Set up https server
if c.Web.HTTPS != "" { if c.Web.HTTPS != "" {
const name = "https" logger.Infof("listening (https) on %s", c.Web.HTTPS)
go func() {
logger.Infof("listening (%s) on %s", name, c.Web.HTTPS) err := http.ListenAndServeTLS(c.Web.HTTPS, c.Web.TLSCert, c.Web.TLSKey, serv)
errc <- fmt.Errorf("listening on %s failed: %v", c.Web.HTTPS, err)
l, err := net.Listen("tcp", c.Web.HTTPS) }()
if err != nil {
return fmt.Errorf("listening (%s) on %s: %v", name, c.Web.HTTPS, err)
} }
server := &http.Server{
Handler: serv,
TLSConfig: &tls.Config{
CipherSuites: allowedTLSCiphers,
PreferServerCipherSuites: true,
MinVersion: tls.VersionTLS12,
},
}
defer server.Close()
group.Add(func() error {
return server.ServeTLS(l, c.Web.TLSCert, c.Web.TLSKey)
}, func(err error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
logger.Debugf("starting graceful shutdown (%s)", name)
if err := server.Shutdown(ctx); err != nil {
logger.Errorf("graceful shutdown (%s): %v", name, err)
}
})
}
// Set up grpc server
if c.GRPC.Addr != "" { if c.GRPC.Addr != "" {
logger.Infof("listening (grpc) on %s", c.GRPC.Addr) logger.Infof("listening (grpc) on %s", c.GRPC.Addr)
go func() {
grpcListener, err := net.Listen("tcp", c.GRPC.Addr) errc <- func() error {
list, err := net.Listen("tcp", c.GRPC.Addr)
if err != nil { if err != nil {
return fmt.Errorf("listening (grcp) on %s: %w", c.GRPC.Addr, err) return fmt.Errorf("listening on %s failed: %v", c.GRPC.Addr, err)
}
s := grpc.NewServer(grpcOptions...)
api.RegisterDexServer(s, server.NewAPI(serverConfig.Storage, logger))
err = s.Serve(list)
return fmt.Errorf("listening on %s failed: %v", c.GRPC.Addr, err)
}()
}()
} }
grpcSrv := grpc.NewServer(grpcOptions...) return <-errc
api.RegisterDexServer(grpcSrv, server.NewAPI(serverConfig.Storage, logger, version))
grpcMetrics.InitializeMetrics(grpcSrv)
if c.GRPC.Reflection {
logger.Info("enabling reflection in grpc service")
reflection.Register(grpcSrv)
}
group.Add(func() error {
return grpcSrv.Serve(grpcListener)
}, func(err error) {
logger.Debugf("starting graceful shutdown (grpc)")
grpcSrv.GracefulStop()
})
}
group.Add(run.SignalHandler(context.Background(), os.Interrupt, syscall.SIGTERM))
if err := group.Run(); err != nil {
if _, ok := err.(run.SignalError); !ok {
return fmt.Errorf("run groups: %w", err)
}
logger.Infof("%v, shutdown now", err)
}
return nil
} }
var ( var (
@ -504,7 +271,7 @@ func (f *utcFormatter) Format(e *logrus.Entry) ([]byte, error) {
return f.f.Format(e) return f.f.Format(e)
} }
func newLogger(level string, format string) (log.Logger, error) { func newLogger(level string, format string) (logrus.FieldLogger, error) {
var logLevel logrus.Level var logLevel logrus.Level
switch strings.ToLower(level) { switch strings.ToLower(level) {
case "debug": case "debug":
@ -533,33 +300,3 @@ func newLogger(level string, format string) (log.Logger, error) {
Level: logLevel, Level: logLevel,
}, nil }, nil
} }
func applyConfigOverrides(options serveOptions, config *Config) {
if options.webHTTPAddr != "" {
config.Web.HTTP = options.webHTTPAddr
}
if options.webHTTPSAddr != "" {
config.Web.HTTPS = options.webHTTPSAddr
}
if options.telemetryAddr != "" {
config.Telemetry.HTTP = options.telemetryAddr
}
if options.grpcAddr != "" {
config.GRPC.Addr = options.grpcAddr
}
if config.Frontend.Dir == "" {
config.Frontend.Dir = os.Getenv("DEX_FRONTEND_DIR")
}
}
func pprofHandler(router *http.ServeMux) {
router.HandleFunc("/debug/pprof/", pprof.Index)
router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
router.HandleFunc("/debug/pprof/profile", pprof.Profile)
router.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
router.HandleFunc("/debug/pprof/trace", pprof.Trace)
}

View file

@ -4,23 +4,19 @@ import (
"fmt" "fmt"
"runtime" "runtime"
"github.com/coreos/dex/version"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var version = "DEV"
func commandVersion() *cobra.Command { func commandVersion() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "version", Use: "version",
Short: "Print the version and exit", Short: "Print the version and exit",
Run: func(_ *cobra.Command, _ []string) { Run: func(cmd *cobra.Command, args []string) {
fmt.Printf( fmt.Printf(`dex Version: %s
"Dex Version: %s\nGo Version: %s\nGo OS/ARCH: %s %s\n", Go Version: %s
version, Go OS/ARCH: %s %s
runtime.Version(), `, version.Version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
runtime.GOOS,
runtime.GOARCH,
)
}, },
} }
} }

View file

@ -1,92 +0,0 @@
// Package main provides a utility program to launch the Dex container process with an optional
// templating step (provided by gomplate).
//
// This was originally written as a shell script, but we rewrote it as a Go program so that it could
// run as a raw binary in a distroless container.
package main
import (
"fmt"
"os"
"os/exec"
"strings"
"syscall"
)
func main() {
// Note that this docker-entrypoint program is args[0], and it is provided with the true process
// args.
args := os.Args[1:]
if err := run(args, realExec, realWhich); err != nil {
fmt.Println("error:", err.Error())
os.Exit(1)
}
}
func realExec(fork bool, args ...string) error {
if fork {
if output, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil {
return fmt.Errorf("cannot fork/exec command %s: %w (output: %q)", args, err, string(output))
}
return nil
}
argv0, err := exec.LookPath(args[0])
if err != nil {
return fmt.Errorf("cannot lookup path for command %s: %w", args[0], err)
}
if err := syscall.Exec(argv0, args, os.Environ()); err != nil {
return fmt.Errorf("cannot exec command %s (%q): %w", args, argv0, err)
}
return nil
}
func realWhich(path string) string {
fullPath, err := exec.LookPath(path)
if err != nil {
return ""
}
return fullPath
}
func run(args []string, execFunc func(bool, ...string) error, whichFunc func(string) string) error {
if args[0] != "dex" && args[0] != whichFunc("dex") {
return execFunc(false, args...)
}
if args[1] != "serve" {
return execFunc(false, args...)
}
newArgs := []string{}
for _, tplCandidate := range args {
if hasSuffixes(tplCandidate, ".tpl", ".tmpl", ".yaml") {
tmpFile, err := os.CreateTemp("/tmp", "dex.config.yaml-*")
if err != nil {
return fmt.Errorf("cannot create temp file: %w", err)
}
if err := execFunc(true, "gomplate", "-f", tplCandidate, "-o", tmpFile.Name()); err != nil {
return err
}
newArgs = append(newArgs, tmpFile.Name())
} else {
newArgs = append(newArgs, tplCandidate)
}
}
return execFunc(false, newArgs...)
}
func hasSuffixes(s string, suffixes ...string) bool {
for _, suffix := range suffixes {
if strings.HasSuffix(s, suffix) {
return true
}
}
return false
}

View file

@ -1,113 +0,0 @@
package main
import (
"strings"
"testing"
)
type execArgs struct {
fork bool
argPrefixes []string
}
func TestRun(t *testing.T) {
tests := []struct {
name string
args []string
execReturns error
whichReturns string
wantExecArgs []execArgs
wantErr error
}{
{
name: "executable not dex",
args: []string{"tuna", "fish"},
wantExecArgs: []execArgs{{fork: false, argPrefixes: []string{"tuna", "fish"}}},
},
{
name: "executable is full path to dex",
args: []string{"/usr/local/bin/dex", "marshmallow", "zelda"},
whichReturns: "/usr/local/bin/dex",
wantExecArgs: []execArgs{{fork: false, argPrefixes: []string{"/usr/local/bin/dex", "marshmallow", "zelda"}}},
},
{
name: "command is not serve",
args: []string{"dex", "marshmallow", "zelda"},
wantExecArgs: []execArgs{{fork: false, argPrefixes: []string{"dex", "marshmallow", "zelda"}}},
},
{
name: "no templates",
args: []string{"dex", "serve", "config.yaml.not-a-template"},
wantExecArgs: []execArgs{{fork: false, argPrefixes: []string{"dex", "serve", "config.yaml.not-a-template"}}},
},
{
name: "no templates",
args: []string{"dex", "serve", "config.yaml.not-a-template"},
wantExecArgs: []execArgs{{fork: false, argPrefixes: []string{"dex", "serve", "config.yaml.not-a-template"}}},
},
{
name: ".tpl template",
args: []string{"dex", "serve", "config.tpl"},
wantExecArgs: []execArgs{
{fork: true, argPrefixes: []string{"gomplate", "-f", "config.tpl", "-o", "/tmp/dex.config.yaml-"}},
{fork: false, argPrefixes: []string{"dex", "serve", "/tmp/dex.config.yaml-"}},
},
},
{
name: ".tmpl template",
args: []string{"dex", "serve", "config.tmpl"},
wantExecArgs: []execArgs{
{fork: true, argPrefixes: []string{"gomplate", "-f", "config.tmpl", "-o", "/tmp/dex.config.yaml-"}},
{fork: false, argPrefixes: []string{"dex", "serve", "/tmp/dex.config.yaml-"}},
},
},
{
name: ".yaml template",
args: []string{"dex", "serve", "some/path/config.yaml"},
wantExecArgs: []execArgs{
{fork: true, argPrefixes: []string{"gomplate", "-f", "some/path/config.yaml", "-o", "/tmp/dex.config.yaml-"}},
{fork: false, argPrefixes: []string{"dex", "serve", "/tmp/dex.config.yaml-"}},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var gotExecForks []bool
var gotExecArgs [][]string
fakeExec := func(fork bool, args ...string) error {
gotExecForks = append(gotExecForks, fork)
gotExecArgs = append(gotExecArgs, args)
return test.execReturns
}
fakeWhich := func(_ string) string { return test.whichReturns }
gotErr := run(test.args, fakeExec, fakeWhich)
if (test.wantErr == nil) != (gotErr == nil) {
t.Errorf("wanted error %s, got %s", test.wantErr, gotErr)
}
if !execArgsMatch(test.wantExecArgs, gotExecForks, gotExecArgs) {
t.Errorf("wanted exec args %+v, got %+v %+v", test.wantExecArgs, gotExecForks, gotExecArgs)
}
})
}
}
func execArgsMatch(wantExecArgs []execArgs, gotForks []bool, gotExecArgs [][]string) bool {
if len(wantExecArgs) != len(gotForks) {
return false
}
for i := range wantExecArgs {
if wantExecArgs[i].fork != gotForks[i] {
return false
}
for j := range wantExecArgs[i].argPrefixes {
if !strings.HasPrefix(gotExecArgs[i][j], wantExecArgs[i].argPrefixes[j]) {
return false
}
}
}
return true
}

View file

@ -8,6 +8,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io/ioutil"
"log" "log"
"net" "net"
"net/http" "net/http"
@ -17,7 +18,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
@ -42,7 +43,7 @@ type app struct {
// return an HTTP client which trusts the provided root CAs. // return an HTTP client which trusts the provided root CAs.
func httpClientForRootCAs(rootCAs string) (*http.Client, error) { func httpClientForRootCAs(rootCAs string) (*http.Client, error) {
tlsConfig := tls.Config{RootCAs: x509.NewCertPool()} tlsConfig := tls.Config{RootCAs: x509.NewCertPool()}
rootCABytes, err := os.ReadFile(rootCAs) rootCABytes, err := ioutil.ReadFile(rootCAs)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read root-ca: %v", err) return nil, fmt.Errorf("failed to read root-ca: %v", err)
} }
@ -142,7 +143,7 @@ func cmd() *cobra.Command {
ctx := oidc.ClientContext(context.Background(), a.client) ctx := oidc.ClientContext(context.Background(), a.client)
provider, err := oidc.NewProvider(ctx, issuerURL) provider, err := oidc.NewProvider(ctx, issuerURL)
if err != nil { if err != nil {
return fmt.Errorf("failed to query provider %q: %v", issuerURL, err) return fmt.Errorf("Failed to query provider %q: %v", issuerURL, err)
} }
var s struct { var s struct {
@ -152,7 +153,7 @@ func cmd() *cobra.Command {
ScopesSupported []string `json:"scopes_supported"` ScopesSupported []string `json:"scopes_supported"`
} }
if err := provider.Claims(&s); err != nil { if err := provider.Claims(&s); err != nil {
return fmt.Errorf("failed to parse provider scopes_supported: %v", err) return fmt.Errorf("Failed to parse provider scopes_supported: %v", err)
} }
if len(s.ScopesSupported) == 0 { if len(s.ScopesSupported) == 0 {
@ -236,10 +237,6 @@ func (a *app) handleLogin(w http.ResponseWriter, r *http.Request) {
for _, client := range clients { for _, client := range clients {
scopes = append(scopes, "audience:server:client_id:"+client) scopes = append(scopes, "audience:server:client_id:"+client)
} }
connectorID := ""
if id := r.FormValue("connector_id"); id != "" {
connectorID = id
}
authCodeURL := "" authCodeURL := ""
scopes = append(scopes, "openid", "profile", "email") scopes = append(scopes, "openid", "profile", "email")
@ -251,9 +248,6 @@ func (a *app) handleLogin(w http.ResponseWriter, r *http.Request) {
} else { } else {
authCodeURL = a.oauth2Config(scopes).AuthCodeURL(exampleAppState, oauth2.AccessTypeOffline) authCodeURL = a.oauth2Config(scopes).AuthCodeURL(exampleAppState, oauth2.AccessTypeOffline)
} }
if connectorID != "" {
authCodeURL = authCodeURL + "&connector_id=" + connectorID
}
http.Redirect(w, r, authCodeURL, http.StatusSeeOther) http.Redirect(w, r, authCodeURL, http.StatusSeeOther)
} }
@ -267,7 +261,7 @@ func (a *app) handleCallback(w http.ResponseWriter, r *http.Request) {
ctx := oidc.ClientContext(r.Context(), a.client) ctx := oidc.ClientContext(r.Context(), a.client)
oauth2Config := a.oauth2Config(nil) oauth2Config := a.oauth2Config(nil)
switch r.Method { switch r.Method {
case http.MethodGet: case "GET":
// Authorization redirect callback from OAuth2 auth flow. // Authorization redirect callback from OAuth2 auth flow.
if errMsg := r.FormValue("error"); errMsg != "" { if errMsg := r.FormValue("error"); errMsg != "" {
http.Error(w, errMsg+": "+r.FormValue("error_description"), http.StatusBadRequest) http.Error(w, errMsg+": "+r.FormValue("error_description"), http.StatusBadRequest)
@ -283,7 +277,7 @@ func (a *app) handleCallback(w http.ResponseWriter, r *http.Request) {
return return
} }
token, err = oauth2Config.Exchange(ctx, code) token, err = oauth2Config.Exchange(ctx, code)
case http.MethodPost: case "POST":
// Form request from frontend to refresh a token. // Form request from frontend to refresh a token.
refresh := r.FormValue("refresh_token") refresh := r.FormValue("refresh_token")
if refresh == "" { if refresh == "" {
@ -313,27 +307,14 @@ func (a *app) handleCallback(w http.ResponseWriter, r *http.Request) {
idToken, err := a.verifier.Verify(r.Context(), rawIDToken) idToken, err := a.verifier.Verify(r.Context(), rawIDToken)
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("failed to verify ID token: %v", err), http.StatusInternalServerError) http.Error(w, fmt.Sprintf("Failed to verify ID token: %v", err), http.StatusInternalServerError)
return return
} }
accessToken, ok := token.Extra("access_token").(string)
if !ok {
http.Error(w, "no access_token in token response", http.StatusInternalServerError)
return
}
var claims json.RawMessage var claims json.RawMessage
if err := idToken.Claims(&claims); err != nil { idToken.Claims(&claims)
http.Error(w, fmt.Sprintf("error decoding ID token claims: %v", err), http.StatusInternalServerError)
return
}
buff := new(bytes.Buffer) buff := new(bytes.Buffer)
if err := json.Indent(buff, []byte(claims), "", " "); err != nil { json.Indent(buff, []byte(claims), "", " ")
http.Error(w, fmt.Sprintf("error indenting ID token claims: %v", err), http.StatusInternalServerError)
return
}
renderToken(w, a.redirectURI, rawIDToken, accessToken, token.RefreshToken, buff.String()) renderToken(w, a.redirectURI, rawIDToken, token.RefreshToken, buff.Bytes())
} }

View file

@ -7,35 +7,18 @@ import (
) )
var indexTmpl = template.Must(template.New("index.html").Parse(`<html> var indexTmpl = template.Must(template.New("index.html").Parse(`<html>
<head>
<style>
form { display: table; }
p { display: table-row; }
label { display: table-cell; }
input { display: table-cell; }
</style>
</head>
<body> <body>
<form action="/login" method="post"> <form action="/login" method="post">
<p> <p>
<label> Authenticate for: </label> Authenticate for:<input type="text" name="cross_client" placeholder="list of client-ids">
<input type="text" name="cross_client" placeholder="list of client-ids">
</p> </p>
<p> <p>
<label>Extra scopes: </label> Extra scopes:<input type="text" name="extra_scopes" placeholder="list of scopes">
<input type="text" name="extra_scopes" placeholder="list of scopes">
</p> </p>
<p> <p>
<label>Connector ID: </label> Request offline access:<input type="checkbox" name="offline_access" value="yes" checked>
<input type="text" name="connector_id" placeholder="connector id">
</p> </p>
<p>
<label>Request offline access: </label>
<input type="checkbox" name="offline_access" value="yes" checked>
</p>
<p>
<input type="submit" value="Login"> <input type="submit" value="Login">
</p>
</form> </form>
</body> </body>
</html>`)) </html>`))
@ -46,7 +29,6 @@ func renderIndex(w http.ResponseWriter) {
type tokenTmplData struct { type tokenTmplData struct {
IDToken string IDToken string
AccessToken string
RefreshToken string RefreshToken string
RedirectURL string RedirectURL string
Claims string Claims string
@ -66,8 +48,7 @@ pre {
</style> </style>
</head> </head>
<body> <body>
<p> ID Token: <pre><code>{{ .IDToken }}</code></pre></p> <p> Token: <pre><code>{{ .IDToken }}</code></pre></p>
<p> Access Token: <pre><code>{{ .AccessToken }}</code></pre></p>
<p> Claims: <pre><code>{{ .Claims }}</code></pre></p> <p> Claims: <pre><code>{{ .Claims }}</code></pre></p>
{{ if .RefreshToken }} {{ if .RefreshToken }}
<p> Refresh Token: <pre><code>{{ .RefreshToken }}</code></pre></p> <p> Refresh Token: <pre><code>{{ .RefreshToken }}</code></pre></p>
@ -80,13 +61,12 @@ pre {
</html> </html>
`)) `))
func renderToken(w http.ResponseWriter, redirectURL, idToken, accessToken, refreshToken, claims string) { func renderToken(w http.ResponseWriter, redirectURL, idToken, refreshToken string, claims []byte) {
renderTemplate(w, tokenTmpl, tokenTmplData{ renderTemplate(w, tokenTmpl, tokenTmplData{
IDToken: idToken, IDToken: idToken,
AccessToken: accessToken,
RefreshToken: refreshToken, RefreshToken: refreshToken,
RedirectURL: redirectURL, RedirectURL: redirectURL,
Claims: claims, Claims: string(claims),
}) })
} }

View file

@ -1,35 +0,0 @@
issuer: http://127.0.0.1:5556/dex
storage:
type: sqlite3
config:
file: var/sqlite/dex.db
web:
http: 127.0.0.1:5556
telemetry:
http: 127.0.0.1:5558
grpc:
addr: 127.0.0.1:5557
staticClients:
- id: example-app
redirectURIs:
- 'http://127.0.0.1:5555/callback'
name: 'Example App'
secret: ZXhhbXBsZS1hcHAtc2VjcmV0
connectors:
- type: mockCallback
id: mock
name: Example
enablePasswordDB: true
staticPasswords:
- email: "admin@example.com"
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
username: "admin"
userID: "08a8684b-db88-4b73-90a9-3cd1661f5466"

View file

@ -1,48 +0,0 @@
{{- /* NOTE: This configuration file is an example and exists only for development purposes. */ -}}
{{- /* To find more about gomplate formatting, please visit its documentation site - https://docs.gomplate.ca/ */ -}}
issuer: {{ getenv "DEX_ISSUER" "http://127.0.0.1:5556/dex" }}
storage:
type: sqlite3
config:
file: {{ getenv "DEX_STORAGE_SQLITE3_CONFIG_FILE" "/var/dex/dex.db" }}
web:
{{- if getenv "DEX_WEB_HTTPS" "" }}
https: {{ .Env.DEX_WEB_HTTPS }}
tlsKey: {{ getenv "DEX_WEB_TLS_KEY" | required "$DEX_WEB_TLS_KEY in case of web.https is enabled" }}
tlsCert: {{ getenv "DEX_WEB_TLS_CERT" | required "$DEX_WEB_TLS_CERT in case of web.https is enabled" }}
{{- end }}
http: {{ getenv "DEX_WEB_HTTP" "0.0.0.0:5556" }}
{{- if getenv "DEX_TELEMETRY_HTTP" }}
telemetry:
http: {{ .Env.DEX_TELEMETRY_HTTP }}
{{- end }}
expiry:
deviceRequests: {{ getenv "DEX_EXPIRY_DEVICE_REQUESTS" "5m" }}
signingKeys: {{ getenv "DEX_EXPIRY_SIGNING_KEYS" "6h" }}
idTokens: {{ getenv "DEX_EXPIRY_ID_TOKENS" "24h" }}
authRequests: {{ getenv "DEX_EXPIRY_AUTH_REQUESTS" "24h" }}
logger:
level: {{ getenv "DEX_LOG_LEVEL" "info" }}
format: {{ getenv "DEX_LOG_FORMAT" "text" }}
oauth2:
responseTypes: {{ getenv "DEX_OAUTH2_RESPONSE_TYPES" "[code]" }}
skipApprovalScreen: {{ getenv "DEX_OAUTH2_SKIP_APPROVAL_SCREEN" "false" }}
alwaysShowLoginScreen: {{ getenv "DEX_OAUTH2_ALWAYS_SHOW_LOGIN_SCREEN" "false" }}
{{- if getenv "DEX_OAUTH2_PASSWORD_CONNECTOR" "" }}
passwordConnector: {{ .Env.DEX_OAUTH2_PASSWORD_CONNECTOR }}
{{- end }}
enablePasswordDB: {{ getenv "DEX_ENABLE_PASSWORD_DB" "true" }}
connectors:
{{- if getenv "DEX_CONNECTORS_ENABLE_MOCK" }}
- type: mockCallback
id: mock
name: Example
{{- end }}

View file

@ -1,136 +0,0 @@
# The base path of Dex and the external name of the OpenID Connect service.
# This is the canonical URL that all clients MUST use to refer to Dex. If a
# path is provided, Dex's HTTP service will listen at a non-root URL.
issuer: http://127.0.0.1:5556/dex
# The storage configuration determines where Dex stores its state.
# Supported options include:
# - SQL flavors
# - key-value stores (eg. etcd)
# - Kubernetes Custom Resources
#
# See the documentation (https://dexidp.io/docs/storage/) for further information.
storage:
type: memory
# type: sqlite3
# config:
# file: /var/dex/dex.db
# type: mysql
# config:
# host: 127.0.0.1
# port: 3306
# database: dex
# user: mysql
# password: mysql
# ssl:
# mode: "false"
# type: postgres
# config:
# host: 127.0.0.1
# port: 5432
# database: dex
# user: postgres
# password: postgres
# ssl:
# mode: disable
# type: etcd
# config:
# endpoints:
# - http://127.0.0.1:2379
# namespace: dex/
# type: kubernetes
# config:
# kubeConfigFile: $HOME/.kube/config
# HTTP service configuration
web:
http: 127.0.0.1:5556
# Uncomment to enable HTTPS endpoint.
# https: 127.0.0.1:5554
# tlsCert: /etc/dex/tls.crt
# tlsKey: /etc/dex/tls.key
# Dex UI configuration
# frontend:
# issuer: dex
# logoURL: theme/logo.png
# dir: ""
# theme: light
# Telemetry configuration
# telemetry:
# http: 127.0.0.1:5558
# logger:
# level: "debug"
# format: "text" # can also be "json"
# gRPC API configuration
# Uncomment this block to enable the gRPC API.
# See the documentation (https://dexidp.io/docs/api/) for further information.
# grpc:
# addr: 127.0.0.1:5557
# tlsCert: examples/grpc-client/server.crt
# tlsKey: examples/grpc-client/server.key
# tlsClientCA: examples/grpc-client/ca.crt
# Expiration configuration for tokens, signing keys, etc.
# expiry:
# deviceRequests: "5m"
# signingKeys: "6h"
# idTokens: "24h"
# refreshTokens:
# disableRotation: false
# reuseInterval: "3s"
# validIfNotUsedFor: "2160h" # 90 days
# absoluteLifetime: "3960h" # 165 days
# OAuth2 configuration
# oauth2:
# # use ["code", "token", "id_token"] to enable implicit flow for web-only clients
# responseTypes: [ "code" ] # also allowed are "token" and "id_token"
#
# # By default, Dex will ask for approval to share data with application
# # (approval for sharing data from connected IdP to Dex is separate process on IdP)
# skipApprovalScreen: false
#
# # If only one authentication method is enabled, the default behavior is to
# # go directly to it. For connected IdPs, this redirects the browser away
# # from application to upstream provider such as the Google login page
# alwaysShowLoginScreen: false
#
# # Uncomment to use a specific connector for password grants
# passwordConnector: local
# Static clients registered in Dex by default.
#
# Alternatively, clients may be added through the gRPC API.
# staticClients:
# - id: example-app
# redirectURIs:
# - 'http://127.0.0.1:5555/callback'
# name: 'Example App'
# secret: ZXhhbXBsZS1hcHAtc2VjcmV0
# Connectors are used to authenticate users agains upstream identity providers.
#
# See the documentation (https://dexidp.io/docs/connectors/) for further information.
# connectors: []
# Enable the password database.
#
# It's a "virtual" connector (identity provider) that stores
# login credentials in Dex's store.
enablePasswordDB: true
# If this option isn't chosen users may be added through the gRPC API.
# A static list of passwords for the password connector.
#
# Alternatively, passwords my be added/updated through the gRPC API.
# staticPasswords: []

View file

@ -1,449 +0,0 @@
// Package atlassiancrowd provides authentication strategies using Atlassian Crowd.
package atlassiancrowd
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"github.com/dexidp/dex/connector"
"github.com/dexidp/dex/pkg/groups"
"github.com/dexidp/dex/pkg/log"
)
// Config holds configuration options for Atlassian Crowd connector.
// Crowd connectors require executing two queries, the first to find
// the user based on the username and password given to the connector.
// The second to use the user entry to search for groups.
//
// An example config:
//
// type: atlassian-crowd
// config:
// baseURL: https://crowd.example.com/context
// clientID: applogin
// clientSecret: appP4$$w0rd
// # users can be restricted by a list of groups
// groups:
// - admin
// # Prompt for username field
// usernamePrompt: Login
// preferredUsernameField: name
//
type Config struct {
BaseURL string `json:"baseURL"`
ClientID string `json:"clientID"`
ClientSecret string `json:"clientSecret"`
Groups []string `json:"groups"`
// PreferredUsernameField allows users to set the field to any of the
// following values: "key", "name" or "email".
// If unset, the preferred_username field will remain empty.
PreferredUsernameField string `json:"preferredUsernameField"`
// UsernamePrompt allows users to override the username attribute (displayed
// in the username/password prompt). If unset, the handler will use.
// "Username".
UsernamePrompt string `json:"usernamePrompt"`
}
type crowdUser struct {
Key string
Name string
Active bool
Email string
}
type crowdGroups struct {
Groups []struct {
Name string
} `json:"groups"`
}
type crowdAuthentication struct {
Token string
User struct {
Name string
} `json:"user"`
CreatedDate uint64 `json:"created-date"`
ExpiryDate uint64 `json:"expiry-date"`
}
type crowdAuthenticationError struct {
Reason string
Message string
}
// Open returns a strategy for logging in through Atlassian Crowd
func (c *Config) Open(_ string, logger log.Logger) (connector.Connector, error) {
if c.BaseURL == "" {
return nil, fmt.Errorf("crowd: no baseURL provided for crowd connector")
}
return &crowdConnector{Config: *c, logger: logger}, nil
}
type crowdConnector struct {
Config
logger log.Logger
}
var (
_ connector.PasswordConnector = (*crowdConnector)(nil)
_ connector.RefreshConnector = (*crowdConnector)(nil)
)
type refreshData struct {
Username string `json:"username"`
}
func (c *crowdConnector) Login(ctx context.Context, s connector.Scopes, username, password string) (ident connector.Identity, validPass bool, err error) {
// make this check to avoid empty passwords.
if password == "" {
return connector.Identity{}, false, nil
}
// We want to return a different error if the user's password is incorrect vs
// if there was an error.
var incorrectPass bool
var user crowdUser
client := c.crowdAPIClient()
if incorrectPass, err = c.authenticateWithPassword(ctx, client, username, password); err != nil {
return connector.Identity{}, false, err
}
if incorrectPass {
return connector.Identity{}, false, nil
}
if user, err = c.user(ctx, client, username); err != nil {
return connector.Identity{}, false, err
}
ident = c.identityFromCrowdUser(user)
if s.Groups {
userGroups, err := c.getGroups(ctx, client, s.Groups, ident.Username)
if err != nil {
return connector.Identity{}, false, fmt.Errorf("crowd: failed to query groups: %v", err)
}
ident.Groups = userGroups
}
if s.OfflineAccess {
refresh := refreshData{Username: username}
// Encode entry for following up requests such as the groups query and refresh attempts.
if ident.ConnectorData, err = json.Marshal(refresh); err != nil {
return connector.Identity{}, false, fmt.Errorf("crowd: marshal refresh data: %v", err)
}
}
return ident, true, nil
}
func (c *crowdConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) {
var data refreshData
if err := json.Unmarshal(ident.ConnectorData, &data); err != nil {
return ident, fmt.Errorf("crowd: failed to unmarshal internal data: %v", err)
}
var user crowdUser
client := c.crowdAPIClient()
user, err := c.user(ctx, client, data.Username)
if err != nil {
return ident, fmt.Errorf("crowd: get user %q: %v", data.Username, err)
}
newIdent := c.identityFromCrowdUser(user)
newIdent.ConnectorData = ident.ConnectorData
// If user exists, authenticate it to prolong sso session.
err = c.authenticateUser(ctx, client, data.Username)
if err != nil {
return ident, fmt.Errorf("crowd: authenticate user: %v", err)
}
if s.Groups {
userGroups, err := c.getGroups(ctx, client, s.Groups, newIdent.Username)
if err != nil {
return connector.Identity{}, fmt.Errorf("crowd: failed to query groups: %v", err)
}
newIdent.Groups = userGroups
}
return newIdent, nil
}
func (c *crowdConnector) Prompt() string {
return c.UsernamePrompt
}
func (c *crowdConnector) crowdAPIClient() *http.Client {
return &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
},
}
}
// authenticateWithPassword creates a new session for user and validates a password with Crowd API
func (c *crowdConnector) authenticateWithPassword(ctx context.Context, client *http.Client, username string, password string) (invalidPass bool, err error) {
req, err := c.crowdUserManagementRequest(ctx,
"POST",
"/session",
struct {
Username string `json:"username"`
Password string `json:"password"`
}{Username: username, Password: password},
)
if err != nil {
return false, fmt.Errorf("crowd: new auth pass api request %v", err)
}
resp, err := client.Do(req)
if err != nil {
return false, fmt.Errorf("crowd: api request %v", err)
}
defer resp.Body.Close()
body, err := c.validateCrowdResponse(resp)
if err != nil {
return false, err
}
if resp.StatusCode != http.StatusCreated {
var authError crowdAuthenticationError
if err := json.Unmarshal(body, &authError); err != nil {
return false, fmt.Errorf("unmarshal auth pass response: %d %v %q", resp.StatusCode, err, string(body))
}
if authError.Reason == "INVALID_USER_AUTHENTICATION" {
return true, nil
}
return false, fmt.Errorf("%s: %s", resp.Status, authError.Message)
}
var authResponse crowdAuthentication
if err := json.Unmarshal(body, &authResponse); err != nil {
return false, fmt.Errorf("decode auth response: %v", err)
}
return false, nil
}
// authenticateUser creates a new session for user without password validations with Crowd API
func (c *crowdConnector) authenticateUser(ctx context.Context, client *http.Client, username string) error {
req, err := c.crowdUserManagementRequest(ctx,
"POST",
"/session?validate-password=false",
struct {
Username string `json:"username"`
}{Username: username},
)
if err != nil {
return fmt.Errorf("crowd: new auth api request %v", err)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("crowd: api request %v", err)
}
defer resp.Body.Close()
body, err := c.validateCrowdResponse(resp)
if err != nil {
return err
}
if resp.StatusCode != http.StatusCreated {
return fmt.Errorf("%s: %s", resp.Status, body)
}
var authResponse crowdAuthentication
if err := json.Unmarshal(body, &authResponse); err != nil {
return fmt.Errorf("decode auth response: %v", err)
}
return nil
}
// user retrieves user info from Crowd API
func (c *crowdConnector) user(ctx context.Context, client *http.Client, username string) (crowdUser, error) {
var user crowdUser
req, err := c.crowdUserManagementRequest(ctx,
"GET",
fmt.Sprintf("/user?username=%s", username),
nil,
)
if err != nil {
return user, fmt.Errorf("crowd: new user api request %v", err)
}
resp, err := client.Do(req)
if err != nil {
return user, fmt.Errorf("crowd: api request %v", err)
}
defer resp.Body.Close()
body, err := c.validateCrowdResponse(resp)
if err != nil {
return user, err
}
if resp.StatusCode != http.StatusOK {
return user, fmt.Errorf("%s: %s", resp.Status, body)
}
if err := json.Unmarshal(body, &user); err != nil {
return user, fmt.Errorf("failed to decode response: %v", err)
}
return user, nil
}
// groups retrieves groups from Crowd API
func (c *crowdConnector) groups(ctx context.Context, client *http.Client, username string) (userGroups []string, err error) {
var crowdGroups crowdGroups
req, err := c.crowdUserManagementRequest(ctx,
"GET",
fmt.Sprintf("/user/group/nested?username=%s", username),
nil,
)
if err != nil {
return nil, fmt.Errorf("crowd: new groups api request %v", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("crowd: api request %v", err)
}
defer resp.Body.Close()
body, err := c.validateCrowdResponse(resp)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s: %s", resp.Status, body)
}
if err := json.Unmarshal(body, &crowdGroups); err != nil {
return nil, fmt.Errorf("failed to decode response: %v", err)
}
for _, group := range crowdGroups.Groups {
userGroups = append(userGroups, group.Name)
}
return userGroups, nil
}
// identityFromCrowdUser converts crowdUser to Identity
func (c *crowdConnector) identityFromCrowdUser(user crowdUser) connector.Identity {
identity := connector.Identity{
Username: user.Name,
UserID: user.Key,
Email: user.Email,
EmailVerified: true,
}
switch c.PreferredUsernameField {
case "key":
identity.PreferredUsername = user.Key
case "name":
identity.PreferredUsername = user.Name
case "email":
identity.PreferredUsername = user.Email
default:
if c.PreferredUsernameField != "" {
c.logger.Warnf("preferred_username left empty. Invalid crowd field mapped to preferred_username: %s", c.PreferredUsernameField)
}
}
return identity
}
// getGroups retrieves a list of user's groups and filters it
func (c *crowdConnector) getGroups(ctx context.Context, client *http.Client, groupScope bool, userLogin string) ([]string, error) {
crowdGroups, err := c.groups(ctx, client, userLogin)
if err != nil {
return nil, err
}
if len(c.Groups) > 0 {
filteredGroups := groups.Filter(crowdGroups, c.Groups)
if len(filteredGroups) == 0 {
return nil, fmt.Errorf("crowd: user %q is not in any of the required groups", userLogin)
}
return filteredGroups, nil
} else if groupScope {
return crowdGroups, nil
}
return nil, nil
}
// crowdUserManagementRequest create a http.Request with basic auth, json payload and Accept header
func (c *crowdConnector) crowdUserManagementRequest(ctx context.Context, method string, apiURL string, jsonPayload interface{}) (*http.Request, error) {
var body io.Reader
if jsonPayload != nil {
jsonData, err := json.Marshal(jsonPayload)
if err != nil {
return nil, fmt.Errorf("crowd: marshal API json payload: %v", err)
}
body = bytes.NewReader(jsonData)
}
req, err := http.NewRequest(method, fmt.Sprintf("%s/rest/usermanagement/1%s", c.BaseURL, apiURL), body)
if err != nil {
return nil, fmt.Errorf("new API req: %v", err)
}
req = req.WithContext(ctx)
// Crowd API requires a basic auth
req.SetBasicAuth(c.ClientID, c.ClientSecret)
req.Header.Set("Accept", "application/json")
if jsonPayload != nil {
req.Header.Set("Content-type", "application/json")
}
return req, nil
}
// validateCrowdResponse validates unique not JSON responses from API
func (c *crowdConnector) validateCrowdResponse(resp *http.Response) ([]byte, error) {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("crowd: read user body: %v", err)
}
if resp.StatusCode == http.StatusForbidden && strings.Contains(string(body), "The server understood the request but refuses to authorize it.") {
c.logger.Debugf("crowd response validation failed: %s", string(body))
return nil, fmt.Errorf("dex is forbidden from making requests to the Atlassian Crowd application by URL %q", c.BaseURL)
}
if resp.StatusCode == http.StatusUnauthorized && string(body) == "Application failed to authenticate" {
c.logger.Debugf("crowd response validation failed: %s", string(body))
return nil, fmt.Errorf("dex failed to authenticate Crowd Application with ID %q", c.ClientID)
}
return body, nil
}

View file

@ -1,189 +0,0 @@
// Package atlassiancrowd provides authentication strategies using Atlassian Crowd.
package atlassiancrowd
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"github.com/sirupsen/logrus"
)
func TestUserGroups(t *testing.T) {
s := newTestServer(map[string]TestServerResponse{
"/rest/usermanagement/1/user/group/nested?username=testuser": {
Body: crowdGroups{Groups: []struct{ Name string }{{Name: "group1"}, {Name: "group2"}}},
Code: 200,
},
})
defer s.Close()
c := newTestCrowdConnector(s.URL)
groups, err := c.getGroups(context.Background(), newClient(), true, "testuser")
expectNil(t, err)
expectEquals(t, groups, []string{"group1", "group2"})
}
func TestUserGroupsWithFiltering(t *testing.T) {
s := newTestServer(map[string]TestServerResponse{
"/rest/usermanagement/1/user/group/nested?username=testuser": {
Body: crowdGroups{Groups: []struct{ Name string }{{Name: "group1"}, {Name: "group2"}}},
Code: 200,
},
})
defer s.Close()
c := newTestCrowdConnector(s.URL)
c.Groups = []string{"group1"}
groups, err := c.getGroups(context.Background(), newClient(), true, "testuser")
expectNil(t, err)
expectEquals(t, groups, []string{"group1"})
}
func TestUserLoginFlow(t *testing.T) {
s := newTestServer(map[string]TestServerResponse{
"/rest/usermanagement/1/session?validate-password=false": {
Body: crowdAuthentication{},
Code: 201,
},
"/rest/usermanagement/1/user?username=testuser": {
Body: crowdUser{Active: true, Name: "testuser", Email: "testuser@example.com"},
Code: 200,
},
"/rest/usermanagement/1/user?username=testuser2": {
Body: `<html>The server understood the request but refuses to authorize it.</html>`,
Code: 403,
},
})
defer s.Close()
c := newTestCrowdConnector(s.URL)
user, err := c.user(context.Background(), newClient(), "testuser")
expectNil(t, err)
expectEquals(t, user.Name, "testuser")
expectEquals(t, user.Email, "testuser@example.com")
err = c.authenticateUser(context.Background(), newClient(), "testuser")
expectNil(t, err)
_, err = c.user(context.Background(), newClient(), "testuser2")
expectEquals(t, err, fmt.Errorf("dex is forbidden from making requests to the Atlassian Crowd application by URL %q", s.URL))
}
func TestUserPassword(t *testing.T) {
s := newTestServer(map[string]TestServerResponse{
"/rest/usermanagement/1/session": {
Body: crowdAuthenticationError{Reason: "INVALID_USER_AUTHENTICATION", Message: "test"},
Code: 401,
},
"/rest/usermanagement/1/session?validate-password=false": {
Body: crowdAuthentication{},
Code: 201,
},
})
defer s.Close()
c := newTestCrowdConnector(s.URL)
invalidPassword, err := c.authenticateWithPassword(context.Background(), newClient(), "testuser", "testpassword")
expectNil(t, err)
expectEquals(t, invalidPassword, true)
err = c.authenticateUser(context.Background(), newClient(), "testuser")
expectNil(t, err)
}
func TestIdentityFromCrowdUser(t *testing.T) {
user := crowdUser{
Key: "12345",
Name: "testuser",
Active: true,
Email: "testuser@example.com",
}
c := newTestCrowdConnector("/")
// Sanity checks
expectEquals(t, user.Name, "testuser")
expectEquals(t, user.Email, "testuser@example.com")
// Test unconfigured behaviour
i := c.identityFromCrowdUser(user)
expectEquals(t, i.UserID, "12345")
expectEquals(t, i.Username, "testuser")
expectEquals(t, i.Email, "testuser@example.com")
expectEquals(t, i.EmailVerified, true)
// Test for various PreferredUsernameField settings
// unset
expectEquals(t, i.PreferredUsername, "")
c.Config.PreferredUsernameField = "key"
i = c.identityFromCrowdUser(user)
expectEquals(t, i.PreferredUsername, "12345")
c.Config.PreferredUsernameField = "name"
i = c.identityFromCrowdUser(user)
expectEquals(t, i.PreferredUsername, "testuser")
c.Config.PreferredUsernameField = "email"
i = c.identityFromCrowdUser(user)
expectEquals(t, i.PreferredUsername, "testuser@example.com")
c.Config.PreferredUsernameField = "invalidstring"
i = c.identityFromCrowdUser(user)
expectEquals(t, i.PreferredUsername, "")
}
type TestServerResponse struct {
Body interface{}
Code int
}
func newTestCrowdConnector(baseURL string) crowdConnector {
connector := crowdConnector{}
connector.BaseURL = baseURL
connector.logger = &logrus.Logger{
Out: io.Discard,
Level: logrus.DebugLevel,
Formatter: &logrus.TextFormatter{DisableColors: true},
}
return connector
}
func newTestServer(responses map[string]TestServerResponse) *httptest.Server {
s := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response := responses[r.RequestURI]
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(response.Code)
json.NewEncoder(w).Encode(response.Body)
}))
return s
}
func newClient() *http.Client {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
return &http.Client{Transport: tr}
}
func expectNil(t *testing.T, a interface{}) {
if a != nil {
t.Errorf("Expected %+v to equal nil", a)
}
}
func expectEquals(t *testing.T, a interface{}, b interface{}) {
if !reflect.DeepEqual(a, b) {
t.Errorf("Expected %+v to equal %+v", a, b)
}
}

View file

@ -1,80 +0,0 @@
// Package authproxy implements a connector which relies on external
// authentication (e.g. mod_auth in Apache2) and returns an identity with the
// HTTP header X-Remote-User as verified email.
package authproxy
import (
"fmt"
"net/http"
"net/url"
"github.com/dexidp/dex/connector"
"github.com/dexidp/dex/pkg/log"
)
// Config holds the configuration parameters for a connector which returns an
// identity with the HTTP header X-Remote-User as verified email,
// X-Remote-Group and configured staticGroups as user's group.
// Headers retrieved to fetch user's email and group can be configured
// with userHeader and groupHeader.
type Config struct {
UserHeader string `json:"userHeader"`
GroupHeader string `json:"groupHeader"`
Groups []string `json:"staticGroups"`
}
// Open returns an authentication strategy which requires no user interaction.
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
userHeader := c.UserHeader
if userHeader == "" {
userHeader = "X-Remote-User"
}
groupHeader := c.GroupHeader
if groupHeader == "" {
groupHeader = "X-Remote-Group"
}
return &callback{userHeader: userHeader, groupHeader: groupHeader, logger: logger, pathSuffix: "/" + id, groups: c.Groups}, nil
}
// Callback is a connector which returns an identity with the HTTP header
// X-Remote-User as verified email.
type callback struct {
userHeader string
groupHeader string
groups []string
logger log.Logger
pathSuffix string
}
// LoginURL returns the URL to redirect the user to login with.
func (m *callback) LoginURL(s connector.Scopes, callbackURL, state string) (string, error) {
u, err := url.Parse(callbackURL)
if err != nil {
return "", fmt.Errorf("failed to parse callbackURL %q: %v", callbackURL, err)
}
u.Path += m.pathSuffix
v := u.Query()
v.Set("state", state)
u.RawQuery = v.Encode()
return u.String(), nil
}
// HandleCallback parses the request and returns the user's identity
func (m *callback) HandleCallback(s connector.Scopes, r *http.Request) (connector.Identity, error) {
remoteUser := r.Header.Get(m.userHeader)
if remoteUser == "" {
return connector.Identity{}, fmt.Errorf("required HTTP header %s is not set", m.userHeader)
}
groups := m.groups
headerGroup := r.Header.Get(m.groupHeader)
if headerGroup != "" {
groups = append(groups, headerGroup)
}
return connector.Identity{
UserID: remoteUser, // TODO: figure out if this is a bad ID value.
Email: remoteUser,
EmailVerified: true,
Groups: groups,
}, nil
}

View file

@ -1,468 +0,0 @@
// Package bitbucketcloud provides authentication strategies using Bitbucket Cloud.
package bitbucketcloud
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sync"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/bitbucket"
"github.com/dexidp/dex/connector"
"github.com/dexidp/dex/pkg/groups"
"github.com/dexidp/dex/pkg/log"
)
const (
apiURL = "https://api.bitbucket.org/2.0"
// Switch to API v2.0 when the Atlassian platform services are fully available in Bitbucket
legacyAPIURL = "https://api.bitbucket.org/1.0"
// Bitbucket requires this scope to access '/user' API endpoints.
scopeAccount = "account"
// Bitbucket requires this scope to access '/user/emails' API endpoints.
scopeEmail = "email"
// Bitbucket requires this scope to access '/teams' API endpoints
// which are used when a client includes the 'groups' scope.
scopeTeams = "team"
)
// Config holds configuration options for Bitbucket logins.
type Config struct {
ClientID string `json:"clientID"`
ClientSecret string `json:"clientSecret"`
RedirectURI string `json:"redirectURI"`
Teams []string `json:"teams"`
IncludeTeamGroups bool `json:"includeTeamGroups,omitempty"`
}
// Open returns a strategy for logging in through Bitbucket.
func (c *Config) Open(_ string, logger log.Logger) (connector.Connector, error) {
b := bitbucketConnector{
redirectURI: c.RedirectURI,
teams: c.Teams,
clientID: c.ClientID,
clientSecret: c.ClientSecret,
includeTeamGroups: c.IncludeTeamGroups,
apiURL: apiURL,
legacyAPIURL: legacyAPIURL,
logger: logger,
}
return &b, nil
}
type connectorData struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
Expiry time.Time `json:"expiry"`
}
var (
_ connector.CallbackConnector = (*bitbucketConnector)(nil)
_ connector.RefreshConnector = (*bitbucketConnector)(nil)
)
type bitbucketConnector struct {
redirectURI string
teams []string
clientID string
clientSecret string
logger log.Logger
apiURL string
legacyAPIURL string
// the following are used only for tests
hostName string
httpClient *http.Client
includeTeamGroups bool
}
// groupsRequired returns whether dex requires Bitbucket's 'team' scope.
func (b *bitbucketConnector) groupsRequired(groupScope bool) bool {
return len(b.teams) > 0 || groupScope
}
func (b *bitbucketConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config {
bitbucketScopes := []string{scopeAccount, scopeEmail}
if b.groupsRequired(scopes.Groups) {
bitbucketScopes = append(bitbucketScopes, scopeTeams)
}
endpoint := bitbucket.Endpoint
if b.hostName != "" {
endpoint = oauth2.Endpoint{
AuthURL: "https://" + b.hostName + "/site/oauth2/authorize",
TokenURL: "https://" + b.hostName + "/site/oauth2/access_token",
}
}
return &oauth2.Config{
ClientID: b.clientID,
ClientSecret: b.clientSecret,
Endpoint: endpoint,
Scopes: bitbucketScopes,
}
}
func (b *bitbucketConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) {
if b.redirectURI != callbackURL {
return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, b.redirectURI)
}
return b.oauth2Config(scopes).AuthCodeURL(state), nil
}
type oauth2Error struct {
error string
errorDescription string
}
func (e *oauth2Error) Error() string {
if e.errorDescription == "" {
return e.error
}
return e.error + ": " + e.errorDescription
}
func (b *bitbucketConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) {
q := r.URL.Query()
if errType := q.Get("error"); errType != "" {
return identity, &oauth2Error{errType, q.Get("error_description")}
}
oauth2Config := b.oauth2Config(s)
ctx := r.Context()
if b.httpClient != nil {
ctx = context.WithValue(r.Context(), oauth2.HTTPClient, b.httpClient)
}
token, err := oauth2Config.Exchange(ctx, q.Get("code"))
if err != nil {
return identity, fmt.Errorf("bitbucket: failed to get token: %v", err)
}
client := oauth2Config.Client(ctx, token)
user, err := b.user(ctx, client)
if err != nil {
return identity, fmt.Errorf("bitbucket: get user: %v", err)
}
identity = connector.Identity{
UserID: user.UUID,
Username: user.Username,
Email: user.Email,
EmailVerified: true,
}
if b.groupsRequired(s.Groups) {
groups, err := b.getGroups(ctx, client, s.Groups, user.Username)
if err != nil {
return identity, err
}
identity.Groups = groups
}
if s.OfflineAccess {
data := connectorData{
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
Expiry: token.Expiry,
}
connData, err := json.Marshal(data)
if err != nil {
return identity, fmt.Errorf("bitbucket: marshal connector data: %v", err)
}
identity.ConnectorData = connData
}
return identity, nil
}
// Refreshing tokens
// https://github.com/golang/oauth2/issues/84#issuecomment-332860871
type tokenNotifyFunc func(*oauth2.Token) error
// notifyRefreshTokenSource is essentially `oauth2.ReuseTokenSource` with `TokenNotifyFunc` added.
type notifyRefreshTokenSource struct {
new oauth2.TokenSource
mu sync.Mutex // guards t
t *oauth2.Token
f tokenNotifyFunc // called when token refreshed so new refresh token can be persisted
}
// Token returns the current token if it's still valid, else will
// refresh the current token (using r.Context for HTTP client
// information) and return the new one.
func (s *notifyRefreshTokenSource) Token() (*oauth2.Token, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.t.Valid() {
return s.t, nil
}
t, err := s.new.Token()
if err != nil {
return nil, err
}
s.t = t
return t, s.f(t)
}
func (b *bitbucketConnector) Refresh(ctx context.Context, s connector.Scopes, identity connector.Identity) (connector.Identity, error) {
if len(identity.ConnectorData) == 0 {
return identity, errors.New("bitbucket: no upstream access token found")
}
var data connectorData
if err := json.Unmarshal(identity.ConnectorData, &data); err != nil {
return identity, fmt.Errorf("bitbucket: unmarshal access token: %v", err)
}
tok := &oauth2.Token{
AccessToken: data.AccessToken,
RefreshToken: data.RefreshToken,
Expiry: data.Expiry,
}
client := oauth2.NewClient(ctx, &notifyRefreshTokenSource{
new: b.oauth2Config(s).TokenSource(ctx, tok),
t: tok,
f: func(tok *oauth2.Token) error {
data := connectorData{
AccessToken: tok.AccessToken,
RefreshToken: tok.RefreshToken,
Expiry: tok.Expiry,
}
connData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("bitbucket: marshal connector data: %v", err)
}
identity.ConnectorData = connData
return nil
},
})
user, err := b.user(ctx, client)
if err != nil {
return identity, fmt.Errorf("bitbucket: get user: %v", err)
}
identity.Username = user.Username
identity.Email = user.Email
if b.groupsRequired(s.Groups) {
groups, err := b.getGroups(ctx, client, s.Groups, user.Username)
if err != nil {
return identity, err
}
identity.Groups = groups
}
return identity, nil
}
// Bitbucket pagination wrapper
type pagedResponse struct {
Size int `json:"size"`
Page int `json:"page"`
PageLen int `json:"pagelen"`
Next *string `json:"next"`
Previous *string `json:"previous"`
}
// user holds Bitbucket user information (relevant to dex) as defined by
// https://developer.atlassian.com/bitbucket/api/2/reference/resource/user
type user struct {
Username string `json:"username"`
UUID string `json:"uuid"`
Email string `json:"email"`
}
// user queries the Bitbucket API for profile information using the provided client.
//
// The HTTP client is expected to be constructed by the golang.org/x/oauth2 package,
// which inserts a bearer token as part of the request.
func (b *bitbucketConnector) user(ctx context.Context, client *http.Client) (user, error) {
// https://developer.atlassian.com/bitbucket/api/2/reference/resource/user
var (
u user
err error
)
if err = get(ctx, client, b.apiURL+"/user", &u); err != nil {
return user{}, err
}
if u.Email, err = b.userEmail(ctx, client); err != nil {
return user{}, err
}
return u, nil
}
// userEmail holds Bitbucket user email information as defined by
// https://developer.atlassian.com/bitbucket/api/2/reference/resource/user/emails
type userEmail struct {
IsPrimary bool `json:"is_primary"`
IsConfirmed bool `json:"is_confirmed"`
Email string `json:"email"`
}
type userEmailResponse struct {
pagedResponse
Values []userEmail
}
// userEmail returns the users primary, confirmed email
//
// The HTTP client is expected to be constructed by the golang.org/x/oauth2 package,
// which inserts a bearer token as part of the request.
func (b *bitbucketConnector) userEmail(ctx context.Context, client *http.Client) (string, error) {
apiURL := b.apiURL + "/user/emails"
for {
// https://developer.atlassian.com/bitbucket/api/2/reference/resource/user/emails
var response userEmailResponse
if err := get(ctx, client, apiURL, &response); err != nil {
return "", err
}
for _, email := range response.Values {
if email.IsConfirmed && email.IsPrimary {
return email.Email, nil
}
}
if response.Next == nil {
break
}
}
return "", errors.New("bitbucket: user has no confirmed, primary email")
}
// getGroups retrieves Bitbucket teams a user is in, if any.
func (b *bitbucketConnector) getGroups(ctx context.Context, client *http.Client, groupScope bool, userLogin string) ([]string, error) {
bitbucketTeams, err := b.userWorkspaces(ctx, client)
if err != nil {
return nil, err
}
if len(b.teams) > 0 {
filteredTeams := groups.Filter(bitbucketTeams, b.teams)
if len(filteredTeams) == 0 {
return nil, fmt.Errorf("bitbucket: user %q is not in any of the required teams", userLogin)
}
return filteredTeams, nil
} else if groupScope {
return bitbucketTeams, nil
}
return nil, nil
}
type workspaceSlug struct {
Slug string `json:"slug"`
}
type workspace struct {
Workspace workspaceSlug `json:"workspace"`
}
type userWorkspacesResponse struct {
pagedResponse
Values []workspace `json:"values"`
}
func (b *bitbucketConnector) userWorkspaces(ctx context.Context, client *http.Client) ([]string, error) {
var teams []string
apiURL := b.apiURL + "/user/permissions/workspaces"
for {
// https://developer.atlassian.com/cloud/bitbucket/rest/api-group-workspaces/#api-workspaces-get
var response userWorkspacesResponse
if err := get(ctx, client, apiURL, &response); err != nil {
return nil, fmt.Errorf("bitbucket: get user teams: %v", err)
}
for _, value := range response.Values {
teams = append(teams, value.Workspace.Slug)
}
if response.Next == nil {
break
}
}
if b.includeTeamGroups {
for _, team := range teams {
teamGroups, err := b.userTeamGroups(ctx, client, team)
if err != nil {
return nil, fmt.Errorf("bitbucket: %v", err)
}
teams = append(teams, teamGroups...)
}
}
return teams, nil
}
type group struct {
Slug string `json:"slug"`
}
func (b *bitbucketConnector) userTeamGroups(ctx context.Context, client *http.Client, teamName string) ([]string, error) {
apiURL := b.legacyAPIURL + "/groups/" + teamName
var response []group
if err := get(ctx, client, apiURL, &response); err != nil {
return nil, fmt.Errorf("get user team %q groups: %v", teamName, err)
}
teamGroups := make([]string, 0, len(response))
for _, group := range response {
teamGroups = append(teamGroups, teamName+"/"+group.Slug)
}
return teamGroups, nil
}
// get creates a "GET `apiURL`" request with context, sends the request using
// the client, and decodes the resulting response body into v.
// Any errors encountered when building requests, sending requests, and
// reading and decoding response data are returned.
func get(ctx context.Context, client *http.Client, apiURL string, v interface{}) error {
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return fmt.Errorf("bitbucket: new req: %v", err)
}
req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("bitbucket: get URL %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("bitbucket: read body: %s: %v", resp.Status, err)
}
return fmt.Errorf("%s: %s", resp.Status, body)
}
if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
return fmt.Errorf("bitbucket: failed to decode response: %v", err)
}
return nil
}

View file

@ -1,137 +0,0 @@
package bitbucketcloud
import (
"context"
"crypto/tls"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"testing"
"github.com/dexidp/dex/connector"
)
func TestUserGroups(t *testing.T) {
teamsResponse := userWorkspacesResponse{
pagedResponse: pagedResponse{
Size: 3,
Page: 1,
PageLen: 10,
},
Values: []workspace{
{Workspace: workspaceSlug{Slug: "team-1"}},
{Workspace: workspaceSlug{Slug: "team-2"}},
{Workspace: workspaceSlug{Slug: "team-3"}},
},
}
s := newTestServer(map[string]interface{}{
"/user/permissions/workspaces": teamsResponse,
"/groups/team-1": []group{{Slug: "administrators"}, {Slug: "members"}},
"/groups/team-2": []group{{Slug: "everyone"}},
"/groups/team-3": []group{},
})
connector := bitbucketConnector{apiURL: s.URL, legacyAPIURL: s.URL}
groups, err := connector.userWorkspaces(context.Background(), newClient())
expectNil(t, err)
expectEquals(t, groups, []string{
"team-1",
"team-2",
"team-3",
})
connector.includeTeamGroups = true
groups, err = connector.userWorkspaces(context.Background(), newClient())
expectNil(t, err)
expectEquals(t, groups, []string{
"team-1",
"team-2",
"team-3",
"team-1/administrators",
"team-1/members",
"team-2/everyone",
})
s.Close()
}
func TestUserWithoutTeams(t *testing.T) {
s := newTestServer(map[string]interface{}{
"/user/permissions/workspaces": userWorkspacesResponse{},
})
connector := bitbucketConnector{apiURL: s.URL}
groups, err := connector.userWorkspaces(context.Background(), newClient())
expectNil(t, err)
expectEquals(t, len(groups), 0)
s.Close()
}
func TestUsernameIncludedInFederatedIdentity(t *testing.T) {
s := newTestServer(map[string]interface{}{
"/user": user{Username: "some-login"},
"/user/emails": userEmailResponse{
pagedResponse: pagedResponse{
Size: 1,
Page: 1,
PageLen: 10,
},
Values: []userEmail{{
Email: "some@email.com",
IsConfirmed: true,
IsPrimary: true,
}},
},
"/site/oauth2/access_token": map[string]interface{}{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
"expires_in": "30",
},
})
hostURL, err := url.Parse(s.URL)
expectNil(t, err)
req, err := http.NewRequest("GET", hostURL.String(), nil)
expectNil(t, err)
bitbucketConnector := bitbucketConnector{apiURL: s.URL, hostName: hostURL.Host, httpClient: newClient()}
identity, err := bitbucketConnector.HandleCallback(connector.Scopes{}, req)
expectNil(t, err)
expectEquals(t, identity.Username, "some-login")
s.Close()
}
func newTestServer(responses map[string]interface{}) *httptest.Server {
return httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", "application/json")
json.NewEncoder(w).Encode(responses[r.URL.String()])
}))
}
func newClient() *http.Client {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
return &http.Client{Transport: tr}
}
func expectNil(t *testing.T, a interface{}) {
if a != nil {
t.Fatalf("Expected %+v to equal nil", a)
}
}
func expectEquals(t *testing.T, a interface{}, b interface{}) {
if !reflect.DeepEqual(a, b) {
t.Fatalf("Expected %+v to equal %+v", a, b)
}
}

View file

@ -25,7 +25,6 @@ type Scopes struct {
type Identity struct { type Identity struct {
UserID string UserID string
Username string Username string
PreferredUsername string
Email string Email string
EmailVerified bool EmailVerified bool
@ -40,10 +39,7 @@ type Identity struct {
// PasswordConnector is an interface implemented by connectors which take a // PasswordConnector is an interface implemented by connectors which take a
// username and password. // username and password.
// Prompt() is used to inform the handler what to display in the password
// template. If this returns an empty string, it'll default to "Username".
type PasswordConnector interface { type PasswordConnector interface {
Prompt() string
Login(ctx context.Context, s Scopes, username, password string) (identity Identity, validPassword bool, err error) Login(ctx context.Context, s Scopes, username, password string) (identity Identity, validPassword bool, err error)
} }
@ -80,7 +76,7 @@ type SAMLConnector interface {
// //
// POSTData should encode the provided request ID in the returned serialized // POSTData should encode the provided request ID in the returned serialized
// SAML request. // SAML request.
POSTData(s Scopes, requestID string) (ssoURL, samlRequest string, err error) POSTData(s Scopes, requestID string) (sooURL, samlRequest string, err error)
// HandlePOST decodes, verifies, and maps attributes from the SAML response. // HandlePOST decodes, verifies, and maps attributes from the SAML response.
// It passes the expected value of the "InResponseTo" response field, which // It passes the expected value of the "InResponseTo" response field, which

View file

@ -1,424 +0,0 @@
// Package gitea provides authentication strategies using Gitea.
package gitea
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"sync"
"time"
"golang.org/x/oauth2"
"github.com/dexidp/dex/connector"
"github.com/dexidp/dex/pkg/log"
)
// Config holds configuration options for gitea logins.
type Config struct {
BaseURL string `json:"baseURL"`
ClientID string `json:"clientID"`
ClientSecret string `json:"clientSecret"`
RedirectURI string `json:"redirectURI"`
Orgs []Org `json:"orgs"`
LoadAllGroups bool `json:"loadAllGroups"`
UseLoginAsID bool `json:"useLoginAsID"`
}
// Org holds org-team filters, in which teams are optional.
type Org struct {
// Organization name in gitea (not slug, full name). Only users in this gitea
// organization can authenticate.
Name string `json:"name"`
// Names of teams in a gitea organization. A user will be able to
// authenticate if they are members of at least one of these teams. Users
// in the organization can authenticate if this field is omitted from the
// config file.
Teams []string `json:"teams,omitempty"`
}
type giteaUser struct {
ID int `json:"id"`
Name string `json:"full_name"`
Username string `json:"login"`
Email string `json:"email"`
IsAdmin bool `json:"is_admin"`
}
// Open returns a strategy for logging in through Gitea
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
if c.BaseURL == "" {
c.BaseURL = "https://gitea.com"
}
return &giteaConnector{
baseURL: c.BaseURL,
redirectURI: c.RedirectURI,
orgs: c.Orgs,
clientID: c.ClientID,
clientSecret: c.ClientSecret,
logger: logger,
loadAllGroups: c.LoadAllGroups,
useLoginAsID: c.UseLoginAsID,
}, nil
}
type connectorData struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
Expiry time.Time `json:"expiry"`
}
var (
_ connector.CallbackConnector = (*giteaConnector)(nil)
_ connector.RefreshConnector = (*giteaConnector)(nil)
)
type giteaConnector struct {
baseURL string
redirectURI string
orgs []Org
clientID string
clientSecret string
logger log.Logger
httpClient *http.Client
// if set to true and no orgs are configured then connector loads all user claims (all orgs and team)
loadAllGroups bool
// if set to true will use the user's handle rather than their numeric id as the ID
useLoginAsID bool
}
func (c *giteaConnector) oauth2Config(_ connector.Scopes) *oauth2.Config {
giteaEndpoint := oauth2.Endpoint{AuthURL: c.baseURL + "/login/oauth/authorize", TokenURL: c.baseURL + "/login/oauth/access_token"}
return &oauth2.Config{
ClientID: c.clientID,
ClientSecret: c.clientSecret,
Endpoint: giteaEndpoint,
RedirectURL: c.redirectURI,
}
}
func (c *giteaConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) {
if c.redirectURI != callbackURL {
return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", c.redirectURI, callbackURL)
}
return c.oauth2Config(scopes).AuthCodeURL(state), nil
}
type oauth2Error struct {
error string
errorDescription string
}
func (e *oauth2Error) Error() string {
if e.errorDescription == "" {
return e.error
}
return e.error + ": " + e.errorDescription
}
func (c *giteaConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) {
q := r.URL.Query()
if errType := q.Get("error"); errType != "" {
return identity, &oauth2Error{errType, q.Get("error_description")}
}
oauth2Config := c.oauth2Config(s)
ctx := r.Context()
if c.httpClient != nil {
ctx = context.WithValue(r.Context(), oauth2.HTTPClient, c.httpClient)
}
token, err := oauth2Config.Exchange(ctx, q.Get("code"))
if err != nil {
return identity, fmt.Errorf("gitea: failed to get token: %v", err)
}
client := oauth2Config.Client(ctx, token)
user, err := c.user(ctx, client)
if err != nil {
return identity, fmt.Errorf("gitea: get user: %v", err)
}
username := user.Name
if username == "" {
username = user.Email
}
identity = connector.Identity{
UserID: strconv.Itoa(user.ID),
Username: username,
PreferredUsername: user.Username,
Email: user.Email,
EmailVerified: true,
}
if c.useLoginAsID {
identity.UserID = user.Username
}
// Only set identity.Groups if 'orgs', 'org', or 'groups' scope are specified.
if c.groupsRequired() {
groups, err := c.getGroups(ctx, client)
if err != nil {
return identity, err
}
identity.Groups = groups
}
if s.OfflineAccess {
data := connectorData{
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
Expiry: token.Expiry,
}
connData, err := json.Marshal(data)
if err != nil {
return identity, fmt.Errorf("gitea: marshal connector data: %v", err)
}
identity.ConnectorData = connData
}
return identity, nil
}
// Refreshing tokens
// https://github.com/golang/oauth2/issues/84#issuecomment-332860871
type tokenNotifyFunc func(*oauth2.Token) error
// notifyRefreshTokenSource is essentially `oauth2.ReuseTokenSource` with `TokenNotifyFunc` added.
type notifyRefreshTokenSource struct {
new oauth2.TokenSource
mu sync.Mutex // guards t
t *oauth2.Token
f tokenNotifyFunc // called when token refreshed so new refresh token can be persisted
}
// Token returns the current token if it's still valid, else will
// refresh the current token (using r.Context for HTTP client
// information) and return the new one.
func (s *notifyRefreshTokenSource) Token() (*oauth2.Token, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.t.Valid() {
return s.t, nil
}
t, err := s.new.Token()
if err != nil {
return nil, err
}
s.t = t
return t, s.f(t)
}
func (c *giteaConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) {
if len(ident.ConnectorData) == 0 {
return ident, errors.New("gitea: no upstream access token found")
}
var data connectorData
if err := json.Unmarshal(ident.ConnectorData, &data); err != nil {
return ident, fmt.Errorf("gitea: unmarshal access token: %v", err)
}
tok := &oauth2.Token{
AccessToken: data.AccessToken,
RefreshToken: data.RefreshToken,
Expiry: data.Expiry,
}
client := oauth2.NewClient(ctx, &notifyRefreshTokenSource{
new: c.oauth2Config(s).TokenSource(ctx, tok),
t: tok,
f: func(tok *oauth2.Token) error {
data := connectorData{
AccessToken: tok.AccessToken,
RefreshToken: tok.RefreshToken,
Expiry: tok.Expiry,
}
connData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("gitea: marshal connector data: %v", err)
}
ident.ConnectorData = connData
return nil
},
})
user, err := c.user(ctx, client)
if err != nil {
return ident, fmt.Errorf("gitea: get user: %v", err)
}
username := user.Name
if username == "" {
username = user.Email
}
ident.Username = username
ident.PreferredUsername = user.Username
ident.Email = user.Email
// Only set identity.Groups if 'orgs', 'org', or 'groups' scope are specified.
if c.groupsRequired() {
groups, err := c.getGroups(ctx, client)
if err != nil {
return ident, err
}
ident.Groups = groups
}
return ident, nil
}
// getGroups retrieves Gitea orgs and teams a user is in, if any.
func (c *giteaConnector) getGroups(ctx context.Context, client *http.Client) ([]string, error) {
if len(c.orgs) > 0 {
return c.groupsForOrgs(ctx, client)
} else if c.loadAllGroups {
return c.userGroups(ctx, client)
}
return nil, nil
}
// formatTeamName returns unique team name.
// Orgs might have the same team names. To make team name unique it should be prefixed with the org name.
func formatTeamName(org string, team string) string {
return fmt.Sprintf("%s:%s", org, team)
}
// groupsForOrgs returns list of groups that user belongs to in approved list
func (c *giteaConnector) groupsForOrgs(ctx context.Context, client *http.Client) ([]string, error) {
groups, err := c.userGroups(ctx, client)
if err != nil {
return groups, err
}
keys := make(map[string]bool)
for _, o := range c.orgs {
keys[o.Name] = true
if o.Teams != nil {
for _, t := range o.Teams {
keys[formatTeamName(o.Name, t)] = true
}
}
}
atLeastOne := false
filteredGroups := make([]string, 0)
for _, g := range groups {
if _, value := keys[g]; value {
filteredGroups = append(filteredGroups, g)
atLeastOne = true
}
}
if !atLeastOne {
return []string{}, fmt.Errorf("gitea: User does not belong to any of the approved groups")
}
return filteredGroups, nil
}
type organization struct {
ID int64 `json:"id"`
Name string `json:"username"`
}
type team struct {
ID int64 `json:"id"`
Name string `json:"name"`
Organization *organization `json:"organization"`
}
func (c *giteaConnector) userGroups(ctx context.Context, client *http.Client) ([]string, error) {
apiURL := c.baseURL + "/api/v1/user/teams"
groups := make([]string, 0)
page := 1
limit := 20
for {
var teams []team
req, err := http.NewRequest("GET", fmt.Sprintf("%s?page=%d&limit=%d", apiURL, page, limit), nil)
if err != nil {
return groups, fmt.Errorf("gitea: new req: %v", err)
}
req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil {
return groups, fmt.Errorf("gitea: get URL %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return groups, fmt.Errorf("gitea: read body: %v", err)
}
return groups, fmt.Errorf("%s: %s", resp.Status, body)
}
if err := json.NewDecoder(resp.Body).Decode(&teams); err != nil {
return groups, fmt.Errorf("failed to decode response: %v", err)
}
if len(teams) == 0 {
break
}
for _, t := range teams {
groups = append(groups, t.Organization.Name)
groups = append(groups, formatTeamName(t.Organization.Name, t.Name))
}
page++
}
// remove duplicate slice variables
keys := make(map[string]struct{})
list := []string{}
for _, group := range groups {
if _, exists := keys[group]; !exists {
keys[group] = struct{}{}
list = append(list, group)
}
}
groups = list
return groups, nil
}
// user queries the Gitea API for profile information using the provided client. The HTTP
// client is expected to be constructed by the golang.org/x/oauth2 package, which inserts
// a bearer token as part of the request.
func (c *giteaConnector) user(ctx context.Context, client *http.Client) (giteaUser, error) {
var u giteaUser
req, err := http.NewRequest("GET", c.baseURL+"/api/v1/user", nil)
if err != nil {
return u, fmt.Errorf("gitea: new req: %v", err)
}
req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil {
return u, fmt.Errorf("gitea: get URL %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return u, fmt.Errorf("gitea: read body: %v", err)
}
return u, fmt.Errorf("%s: %s", resp.Status, body)
}
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
return u, fmt.Errorf("failed to decode response: %v", err)
}
return u, nil
}
// groupsRequired returns whether dex needs to request groups from Gitea.
func (c *giteaConnector) groupsRequired() bool {
return len(c.orgs) > 0 || c.loadAllGroups
}

View file

@ -1,72 +0,0 @@
package gitea
import (
"crypto/tls"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"testing"
"github.com/dexidp/dex/connector"
)
// tests that the email is used as their username when they have no username set
func TestUsernameIncludedInFederatedIdentity(t *testing.T) {
s := newTestServer(map[string]interface{}{
"/api/v1/user": giteaUser{Email: "some@email.com", ID: 12345678},
"/login/oauth/access_token": map[string]interface{}{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
"expires_in": "30",
},
})
defer s.Close()
hostURL, err := url.Parse(s.URL)
expectNil(t, err)
req, err := http.NewRequest("GET", hostURL.String(), nil)
expectNil(t, err)
c := giteaConnector{baseURL: s.URL, httpClient: newClient()}
identity, err := c.HandleCallback(connector.Scopes{}, req)
expectNil(t, err)
expectEquals(t, identity.Username, "some@email.com")
expectEquals(t, identity.UserID, "12345678")
c = giteaConnector{baseURL: s.URL, httpClient: newClient()}
identity, err = c.HandleCallback(connector.Scopes{}, req)
expectNil(t, err)
expectEquals(t, identity.Username, "some@email.com")
expectEquals(t, identity.UserID, "12345678")
}
func newTestServer(responses map[string]interface{}) *httptest.Server {
return httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response := responses[r.RequestURI]
w.Header().Add("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}))
}
func newClient() *http.Client {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
return &http.Client{Transport: tr}
}
func expectNil(t *testing.T, a interface{}) {
if a != nil {
t.Errorf("Expected %+v to equal nil", a)
}
}
func expectEquals(t *testing.T, a interface{}, b interface{}) {
if !reflect.DeepEqual(a, b) {
t.Errorf("Expected %+v to equal %+v", a, b)
}
}

View file

@ -8,10 +8,9 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io/ioutil"
"net" "net"
"net/http" "net/http"
"os"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@ -20,73 +19,35 @@ import (
"golang.org/x/oauth2" "golang.org/x/oauth2"
"golang.org/x/oauth2/github" "golang.org/x/oauth2/github"
"github.com/dexidp/dex/connector" "github.com/Sirupsen/logrus"
groups_pkg "github.com/dexidp/dex/pkg/groups" "github.com/coreos/dex/connector"
"github.com/dexidp/dex/pkg/log"
) )
const ( const (
apiURL = "https://api.github.com" apiURL = "https://api.github.com"
// GitHub requires this scope to access '/user' and '/user/emails' API endpoints.
scopeEmail = "user:email" scopeEmail = "user:email"
// GitHub requires this scope to access '/user/teams' and '/orgs' API endpoints
// which are used when a client includes the 'groups' scope.
scopeOrgs = "read:org" scopeOrgs = "read:org"
) )
// Pagination URL patterns
// https://developer.github.com/v3/#pagination
var (
reNext = regexp.MustCompile("<([^>]+)>; rel=\"next\"")
reLast = regexp.MustCompile("<([^>]+)>; rel=\"last\"")
)
// Config holds configuration options for github logins. // Config holds configuration options for github logins.
type Config struct { type Config struct {
ClientID string `json:"clientID"` ClientID string `json:"clientID"`
ClientSecret string `json:"clientSecret"` ClientSecret string `json:"clientSecret"`
RedirectURI string `json:"redirectURI"` RedirectURI string `json:"redirectURI"`
Org string `json:"org"` Org string `json:"org"`
Orgs []Org `json:"orgs"`
HostName string `json:"hostName"` HostName string `json:"hostName"`
RootCA string `json:"rootCA"` RootCA string `json:"rootCA"`
TeamNameField string `json:"teamNameField"`
LoadAllGroups bool `json:"loadAllGroups"`
UseLoginAsID bool `json:"useLoginAsID"`
}
// Org holds org-team filters, in which teams are optional.
type Org struct {
// Organization name in github (not slug, full name). Only users in this github
// organization can authenticate.
Name string `json:"name"`
// Names of teams in a github organization. A user will be able to
// authenticate if they are members of at least one of these teams. Users
// in the organization can authenticate if this field is omitted from the
// config file.
Teams []string `json:"teams,omitempty"`
} }
// Open returns a strategy for logging in through GitHub. // Open returns a strategy for logging in through GitHub.
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) { func (c *Config) Open(logger logrus.FieldLogger) (connector.Connector, error) {
if c.Org != "" {
// Return error if both 'org' and 'orgs' fields are used.
if len(c.Orgs) > 0 {
return nil, errors.New("github: cannot use both 'org' and 'orgs' fields simultaneously")
}
logger.Warn("github: legacy field 'org' being used. Switch to the newer 'orgs' field structure")
}
g := githubConnector{ g := githubConnector{
redirectURI: c.RedirectURI, redirectURI: c.RedirectURI,
org: c.Org, org: c.Org,
orgs: c.Orgs,
clientID: c.ClientID, clientID: c.ClientID,
clientSecret: c.ClientSecret, clientSecret: c.ClientSecret,
apiURL: apiURL, apiURL: apiURL,
logger: logger, logger: logger,
useLoginAsID: c.UseLoginAsID,
} }
if c.HostName != "" { if c.HostName != "" {
@ -109,14 +70,7 @@ func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error)
if g.httpClient, err = newHTTPClient(g.rootCA); err != nil { if g.httpClient, err = newHTTPClient(g.rootCA); err != nil {
return nil, fmt.Errorf("failed to create HTTP client: %v", err) return nil, fmt.Errorf("failed to create HTTP client: %v", err)
} }
}
g.loadAllGroups = c.LoadAllGroups
switch c.TeamNameField {
case "name", "slug", "both", "":
g.teamNameField = c.TeamNameField
default:
return nil, fmt.Errorf("invalid connector config: unsupported team name field value `%s`", c.TeamNameField)
} }
return &g, nil return &g, nil
@ -135,42 +89,29 @@ var (
type githubConnector struct { type githubConnector struct {
redirectURI string redirectURI string
org string org string
orgs []Org
clientID string clientID string
clientSecret string clientSecret string
logger log.Logger logger logrus.FieldLogger
// apiURL defaults to "https://api.github.com" // apiURL defaults to "https://api.github.com"
apiURL string apiURL string
// hostName of the GitHub enterprise account. // hostName of the GitHub enterprise account.
hostName string hostName string
// Used to support untrusted/self-signed CA certs. // Used to support untrusted/self-signed CA certs.
rootCA string rootCA string
// HTTP Client that trusts the custom declared rootCA cert. // HTTP Client that trusts the custom delcared rootCA cert.
httpClient *http.Client httpClient *http.Client
// optional choice between 'name' (default) or 'slug'
teamNameField string
// if set to true and no orgs are configured then connector loads all user claims (all orgs and team)
loadAllGroups bool
// if set to true will use the user's handle rather than their numeric id as the ID
useLoginAsID bool
}
// groupsRequired returns whether dex requires GitHub's 'read:org' scope. Dex
// needs 'read:org' if 'orgs' or 'org' fields are populated in a config file.
// Clients can require 'groups' scope without setting 'orgs'/'org'.
func (c *githubConnector) groupsRequired(groupScope bool) bool {
return len(c.orgs) > 0 || c.org != "" || groupScope
} }
func (c *githubConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config { func (c *githubConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config {
// 'read:org' scope is required by the GitHub API, and thus for dex to ensure var githubScopes []string
// a user is a member of orgs and teams provided in configs. if scopes.Groups {
githubScopes := []string{scopeEmail} githubScopes = []string{scopeEmail, scopeOrgs}
if c.groupsRequired(scopes.Groups) { } else {
githubScopes = append(githubScopes, scopeOrgs) githubScopes = []string{scopeEmail}
} }
endpoint := github.Endpoint endpoint := github.Endpoint
// case when it is a GitHub Enterprise account. // case when it is a GitHub Enterprise account.
if c.hostName != "" { if c.hostName != "" {
endpoint = oauth2.Endpoint{ endpoint = oauth2.Endpoint{
@ -184,13 +125,12 @@ func (c *githubConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config {
ClientSecret: c.clientSecret, ClientSecret: c.clientSecret,
Endpoint: endpoint, Endpoint: endpoint,
Scopes: githubScopes, Scopes: githubScopes,
RedirectURL: c.redirectURI,
} }
} }
func (c *githubConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) { func (c *githubConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) {
if c.redirectURI != callbackURL { if c.redirectURI != callbackURL {
return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI) return "", fmt.Errorf("expected callback URL did not match the URL in the config")
} }
return c.oauth2Config(scopes).AuthCodeURL(state), nil return c.oauth2Config(scopes).AuthCodeURL(state), nil
@ -208,10 +148,10 @@ func (e *oauth2Error) Error() string {
return e.error + ": " + e.errorDescription return e.error + ": " + e.errorDescription
} }
// newHTTPClient returns a new HTTP client that trusts the custom declared rootCA cert. // newHTTPClient returns a new HTTP client that trusts the custom delcared rootCA cert.
func newHTTPClient(rootCA string) (*http.Client, error) { func newHTTPClient(rootCA string) (*http.Client, error) {
tlsConfig := tls.Config{RootCAs: x509.NewCertPool()} tlsConfig := tls.Config{RootCAs: x509.NewCertPool()}
rootCABytes, err := os.ReadFile(rootCA) rootCABytes, err := ioutil.ReadFile(rootCA)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read root-ca: %v", err) return nil, fmt.Errorf("failed to read root-ca: %v", err)
} }
@ -266,23 +206,17 @@ func (c *githubConnector) HandleCallback(s connector.Scopes, r *http.Request) (i
if username == "" { if username == "" {
username = user.Login username = user.Login
} }
identity = connector.Identity{ identity = connector.Identity{
UserID: strconv.Itoa(user.ID), UserID: strconv.Itoa(user.ID),
Username: username, Username: username,
PreferredUsername: user.Login,
Email: user.Email, Email: user.Email,
EmailVerified: true, EmailVerified: true,
} }
if c.useLoginAsID {
identity.UserID = user.Login
}
// Only set identity.Groups if 'orgs', 'org', or 'groups' scope are specified. if s.Groups && c.org != "" {
if c.groupsRequired(s.Groups) { groups, err := c.teams(ctx, client, c.org)
groups, err := c.getGroups(ctx, client, s.Groups, user.Login)
if err != nil { if err != nil {
return identity, err return identity, fmt.Errorf("github: get teams: %v", err)
} }
identity.Groups = groups identity.Groups = groups
} }
@ -299,240 +233,39 @@ func (c *githubConnector) HandleCallback(s connector.Scopes, r *http.Request) (i
return identity, nil return identity, nil
} }
func (c *githubConnector) Refresh(ctx context.Context, s connector.Scopes, identity connector.Identity) (connector.Identity, error) { func (c *githubConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) {
if len(identity.ConnectorData) == 0 { if len(ident.ConnectorData) == 0 {
return identity, errors.New("no upstream access token found") return ident, errors.New("no upstream access token found")
} }
var data connectorData var data connectorData
if err := json.Unmarshal(identity.ConnectorData, &data); err != nil { if err := json.Unmarshal(ident.ConnectorData, &data); err != nil {
return identity, fmt.Errorf("github: unmarshal access token: %v", err) return ident, fmt.Errorf("github: unmarshal access token: %v", err)
} }
client := c.oauth2Config(s).Client(ctx, &oauth2.Token{AccessToken: data.AccessToken}) client := c.oauth2Config(s).Client(ctx, &oauth2.Token{AccessToken: data.AccessToken})
user, err := c.user(ctx, client) user, err := c.user(ctx, client)
if err != nil { if err != nil {
return identity, fmt.Errorf("github: get user: %v", err) return ident, fmt.Errorf("github: get user: %v", err)
} }
username := user.Name username := user.Name
if username == "" { if username == "" {
username = user.Login username = user.Login
} }
identity.Username = username ident.Username = username
identity.PreferredUsername = user.Login ident.Email = user.Email
identity.Email = user.Email
// Only set identity.Groups if 'orgs', 'org', or 'groups' scope are specified. if s.Groups && c.org != "" {
if c.groupsRequired(s.Groups) { groups, err := c.teams(ctx, client, c.org)
groups, err := c.getGroups(ctx, client, s.Groups, user.Login)
if err != nil { if err != nil {
return identity, err return ident, fmt.Errorf("github: get teams: %v", err)
} }
identity.Groups = groups ident.Groups = groups
} }
return ident, nil
return identity, nil
} }
// getGroups retrieves GitHub orgs and teams a user is in, if any.
func (c *githubConnector) getGroups(ctx context.Context, client *http.Client, groupScope bool, userLogin string) ([]string, error) {
switch {
case len(c.orgs) > 0:
return c.groupsForOrgs(ctx, client, userLogin)
case c.org != "":
return c.teamsForOrg(ctx, client, c.org)
case groupScope && c.loadAllGroups:
return c.userGroups(ctx, client)
}
return nil, nil
}
// formatTeamName returns unique team name.
// Orgs might have the same team names. To make team name unique it should be prefixed with the org name.
func formatTeamName(org string, team string) string {
return fmt.Sprintf("%s:%s", org, team)
}
// groupsForOrgs enforces org and team constraints on user authorization
// Cases in which user is authorized:
// N orgs, no teams: user is member of at least 1 org
// N orgs, M teams per org: user is member of any team from at least 1 org
// N-1 orgs, M teams per org, 1 org with no teams: user is member of any team
// from at least 1 org, or member of org with no teams
func (c *githubConnector) groupsForOrgs(ctx context.Context, client *http.Client, userName string) ([]string, error) {
groups := make([]string, 0)
var inOrgNoTeams bool
for _, org := range c.orgs {
inOrg, err := c.userInOrg(ctx, client, userName, org.Name)
if err != nil {
return nil, err
}
if !inOrg {
continue
}
teams, err := c.teamsForOrg(ctx, client, org.Name)
if err != nil {
return nil, err
}
// User is in at least one org. User is authorized if no teams are specified
// in config; include all teams in claim. Otherwise filter out teams not in
// 'teams' list in config.
if len(org.Teams) == 0 {
inOrgNoTeams = true
} else if teams = groups_pkg.Filter(teams, org.Teams); len(teams) == 0 {
c.logger.Infof("github: user %q in org %q but no teams", userName, org.Name)
}
for _, teamName := range teams {
groups = append(groups, formatTeamName(org.Name, teamName))
}
}
if inOrgNoTeams || len(groups) > 0 {
return groups, nil
}
return groups, fmt.Errorf("github: user %q not in required orgs or teams", userName)
}
func (c *githubConnector) userGroups(ctx context.Context, client *http.Client) ([]string, error) {
orgs, err := c.userOrgs(ctx, client)
if err != nil {
return nil, err
}
orgTeams, err := c.userOrgTeams(ctx, client)
if err != nil {
return nil, err
}
groups := make([]string, 0)
for _, o := range orgs {
groups = append(groups, o)
if teams, ok := orgTeams[o]; ok {
for _, t := range teams {
groups = append(groups, formatTeamName(o, t))
}
}
}
return groups, nil
}
// userOrgs retrieves list of current user orgs
func (c *githubConnector) userOrgs(ctx context.Context, client *http.Client) ([]string, error) {
groups := make([]string, 0)
apiURL := c.apiURL + "/user/orgs"
for {
// https://developer.github.com/v3/orgs/#list-your-organizations
var (
orgs []org
err error
)
if apiURL, err = get(ctx, client, apiURL, &orgs); err != nil {
return nil, fmt.Errorf("github: get orgs: %v", err)
}
for _, o := range orgs {
groups = append(groups, o.Login)
}
if apiURL == "" {
break
}
}
return groups, nil
}
// userOrgTeams retrieves teams which current user belongs to.
// Method returns a map where key is an org name and value list of teams under the org.
func (c *githubConnector) userOrgTeams(ctx context.Context, client *http.Client) (map[string][]string, error) {
groups := make(map[string][]string)
apiURL := c.apiURL + "/user/teams"
for {
// https://developer.github.com/v3/orgs/teams/#list-user-teams
var (
teams []team
err error
)
if apiURL, err = get(ctx, client, apiURL, &teams); err != nil {
return nil, fmt.Errorf("github: get teams: %v", err)
}
for _, t := range teams {
groups[t.Org.Login] = append(groups[t.Org.Login], c.teamGroupClaims(t)...)
}
if apiURL == "" {
break
}
}
return groups, nil
}
// get creates a "GET `apiURL`" request with context, sends the request using
// the client, and decodes the resulting response body into v. A pagination URL
// is returned if one exists. Any errors encountered when building requests,
// sending requests, and reading and decoding response data are returned.
func get(ctx context.Context, client *http.Client, apiURL string, v interface{}) (string, error) {
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return "", fmt.Errorf("github: new req: %v", err)
}
req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("github: get URL %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("github: read body: %v", err)
}
return "", fmt.Errorf("%s: %s", resp.Status, body)
}
if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
return "", fmt.Errorf("failed to decode response: %v", err)
}
return getPagination(apiURL, resp), nil
}
// getPagination checks the "Link" header field for "next" or "last" pagination URLs,
// and returns "next" page URL or empty string to indicate that there are no more pages.
// Non empty next pages' URL is returned if both "last" and "next" URLs are found and next page
// URL is not equal to last.
//
// https://developer.github.com/v3/#pagination
func getPagination(apiURL string, resp *http.Response) string {
if resp == nil {
return ""
}
links := resp.Header.Get("Link")
if len(reLast.FindStringSubmatch(links)) > 1 {
lastPageURL := reLast.FindStringSubmatch(links)[1]
if apiURL == lastPageURL {
return ""
}
} else {
return ""
}
if len(reNext.FindStringSubmatch(links)) > 1 {
return reNext.FindStringSubmatch(links)[1]
}
return ""
}
// user holds GitHub user information (relevant to dex) as defined by
// https://developer.github.com/v3/users/#response-with-public-profile-information
type user struct { type user struct {
Name string `json:"name"` Name string `json:"name"`
Login string `json:"login"` Login string `json:"login"`
@ -540,169 +273,102 @@ type user struct {
Email string `json:"email"` Email string `json:"email"`
} }
// user queries the GitHub API for profile information using the provided client. // user queries the GitHub API for profile information using the provided client. The HTTP
// // client is expected to be constructed by the golang.org/x/oauth2 package, which inserts
// The HTTP client is expected to be constructed by the golang.org/x/oauth2 package, // a bearer token as part of the request.
// which inserts a bearer token as part of the request.
func (c *githubConnector) user(ctx context.Context, client *http.Client) (user, error) { func (c *githubConnector) user(ctx context.Context, client *http.Client) (user, error) {
// https://developer.github.com/v3/users/#get-the-authenticated-user
var u user var u user
if _, err := get(ctx, client, c.apiURL+"/user", &u); err != nil { req, err := http.NewRequest("GET", c.apiURL+"/user", nil)
return u, err
}
// Only public user emails are returned by 'GET /user'. u.Email will be empty
// if a users' email is private. We must retrieve private emails explicitly.
if u.Email == "" {
var err error
if u.Email, err = c.userEmail(ctx, client); err != nil {
return u, err
}
}
return u, nil
}
// userEmail holds GitHub user email information as defined by
// https://developer.github.com/v3/users/emails/#response
type userEmail struct {
Email string `json:"email"`
Verified bool `json:"verified"`
Primary bool `json:"primary"`
Visibility string `json:"visibility"`
}
// userEmail queries the GitHub API for a users' email information using the
// provided client. Only returns the users' verified, primary email (private or
// public).
//
// The HTTP client is expected to be constructed by the golang.org/x/oauth2 package,
// which inserts a bearer token as part of the request.
func (c *githubConnector) userEmail(ctx context.Context, client *http.Client) (string, error) {
apiURL := c.apiURL + "/user/emails"
for {
// https://developer.github.com/v3/users/emails/#list-email-addresses-for-a-user
var (
emails []userEmail
err error
)
if apiURL, err = get(ctx, client, apiURL, &emails); err != nil {
return "", err
}
for _, email := range emails {
/*
if GitHub Enterprise, set email.Verified to true
This change being made because GitHub Enterprise does not
support email verification. CircleCI indicated that GitHub
advised them not to check for verified emails
(https://circleci.com/enterprise/changelog/#1-47-1).
In addition, GitHub Enterprise support replied to a support
ticket with "There is no way to verify an email address in
GitHub Enterprise."
*/
if c.hostName != "" {
email.Verified = true
}
if email.Verified && email.Primary {
return email.Email, nil
}
}
if apiURL == "" {
break
}
}
return "", errors.New("github: user has no verified, primary email")
}
// userInOrg queries the GitHub API for a users' org membership.
//
// The HTTP passed client is expected to be constructed by the golang.org/x/oauth2 package,
// which inserts a bearer token as part of the request.
func (c *githubConnector) userInOrg(ctx context.Context, client *http.Client, userName, orgName string) (bool, error) {
// requester == user, so GET-ing this endpoint should return 404/302 if user
// is not a member
//
// https://developer.github.com/v3/orgs/members/#check-membership
apiURL := fmt.Sprintf("%s/orgs/%s/members/%s", c.apiURL, orgName, userName)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil { if err != nil {
return false, fmt.Errorf("github: new req: %v", err) return u, fmt.Errorf("github: new req: %v", err)
} }
req = req.WithContext(ctx) req = req.WithContext(ctx)
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return false, fmt.Errorf("github: get teams: %v", err) return u, fmt.Errorf("github: get URL %v", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
switch resp.StatusCode { if resp.StatusCode != http.StatusOK {
case http.StatusNoContent: body, err := ioutil.ReadAll(resp.Body)
case http.StatusFound, http.StatusNotFound: if err != nil {
c.logger.Infof("github: user %q not in org %q or application not authorized to read org data", userName, orgName) return u, fmt.Errorf("github: read body: %v", err)
default: }
err = fmt.Errorf("github: unexpected return status: %q", resp.Status) return u, fmt.Errorf("%s: %s", resp.Status, body)
} }
// 204 if user is a member if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
return resp.StatusCode == http.StatusNoContent, err return u, fmt.Errorf("failed to decode response: %v", err)
}
return u, nil
} }
// teams holds GitHub a users' team information as defined by // teams queries the GitHub API for team membership within a specific organization.
// https://developer.github.com/v3/orgs/teams/#response-12
type team struct {
Name string `json:"name"`
Org org `json:"organization"`
Slug string `json:"slug"`
}
type org struct {
Login string `json:"login"`
}
// teamsForOrg queries the GitHub API for team membership within a specific organization.
// //
// The HTTP passed client is expected to be constructed by the golang.org/x/oauth2 package, // The HTTP passed client is expected to be constructed by the golang.org/x/oauth2 package,
// which inserts a bearer token as part of the request. // which inserts a bearer token as part of the request.
func (c *githubConnector) teamsForOrg(ctx context.Context, client *http.Client, orgName string) ([]string, error) { func (c *githubConnector) teams(ctx context.Context, client *http.Client, org string) ([]string, error) {
apiURL, groups := c.apiURL+"/user/teams", []string{}
groups := []string{}
// https://developer.github.com/v3/#pagination
reNext := regexp.MustCompile("<(.*)>; rel=\"next\"")
reLast := regexp.MustCompile("<(.*)>; rel=\"last\"")
apiURL := c.apiURL + "/user/teams"
for { for {
// https://developer.github.com/v3/orgs/teams/#list-user-teams req, err := http.NewRequest("GET", apiURL, nil)
var (
teams []team if err != nil {
err error return nil, fmt.Errorf("github: new req: %v", err)
) }
if apiURL, err = get(ctx, client, apiURL, &teams); err != nil { req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("github: get teams: %v", err) return nil, fmt.Errorf("github: get teams: %v", err)
} }
defer resp.Body.Close()
for _, t := range teams { if resp.StatusCode != http.StatusOK {
if t.Org.Login == orgName { body, err := ioutil.ReadAll(resp.Body)
groups = append(groups, c.teamGroupClaims(t)...) if err != nil {
return nil, fmt.Errorf("github: read body: %v", err)
}
return nil, fmt.Errorf("%s: %s", resp.Status, body)
}
// https://developer.github.com/v3/orgs/teams/#response-12
var teams []struct {
Name string `json:"name"`
Org struct {
Login string `json:"login"`
} `json:"organization"`
}
if err := json.NewDecoder(resp.Body).Decode(&teams); err != nil {
return nil, fmt.Errorf("github: unmarshal groups: %v", err)
}
for _, team := range teams {
if team.Org.Login == org {
groups = append(groups, team.Name)
} }
} }
if apiURL == "" { links := resp.Header.Get("Link")
if len(reLast.FindStringSubmatch(links)) > 1 {
lastPageURL := reLast.FindStringSubmatch(links)[1]
if apiURL == lastPageURL {
break
}
} else {
break
}
if len(reNext.FindStringSubmatch(links)) > 1 {
apiURL = reNext.FindStringSubmatch(links)[1]
} else {
break break
} }
} }
return groups, nil return groups, nil
} }
// teamGroupClaims returns team slug if 'teamNameField' option is set to
// 'slug', returns the slug *and* name if set to 'both', otherwise returns team
// name.
func (c *githubConnector) teamGroupClaims(t team) []string {
switch c.teamNameField {
case "both":
return []string{t.Name, t.Slug}
case "slug":
return []string{t.Slug}
default:
return []string{t.Name}
}
}

View file

@ -1,238 +0,0 @@
package github
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strings"
"testing"
"github.com/dexidp/dex/connector"
)
type testResponse struct {
data interface{}
nextLink string
lastLink string
}
func TestUserGroups(t *testing.T) {
s := newTestServer(map[string]testResponse{
"/user/orgs": {
data: []org{{Login: "org-1"}, {Login: "org-2"}},
nextLink: "/user/orgs?since=2",
lastLink: "/user/orgs?since=2",
},
"/user/orgs?since=2": {data: []org{{Login: "org-3"}}},
"/user/teams": {
data: []team{
{Name: "team-1", Org: org{Login: "org-1"}},
{Name: "team-2", Org: org{Login: "org-1"}},
},
nextLink: "/user/teams?since=2",
lastLink: "/user/teams?since=2",
},
"/user/teams?since=2": {
data: []team{
{Name: "team-3", Org: org{Login: "org-1"}},
{Name: "team-4", Org: org{Login: "org-2"}},
},
nextLink: "/user/teams?since=2",
lastLink: "/user/teams?since=2",
},
})
defer s.Close()
c := githubConnector{apiURL: s.URL}
groups, err := c.userGroups(context.Background(), newClient())
expectNil(t, err)
expectEquals(t, groups, []string{
"org-1",
"org-1:team-1",
"org-1:team-2",
"org-1:team-3",
"org-2",
"org-2:team-4",
"org-3",
})
}
func TestUserGroupsWithoutOrgs(t *testing.T) {
s := newTestServer(map[string]testResponse{
"/user/orgs": {data: []org{}},
"/user/teams": {data: []team{}},
})
defer s.Close()
c := githubConnector{apiURL: s.URL}
groups, err := c.userGroups(context.Background(), newClient())
expectNil(t, err)
expectEquals(t, len(groups), 0)
}
func TestUserGroupsWithTeamNameFieldConfig(t *testing.T) {
s := newTestServer(map[string]testResponse{
"/user/orgs": {
data: []org{{Login: "org-1"}},
},
"/user/teams": {
data: []team{
{Name: "Team 1", Slug: "team-1", Org: org{Login: "org-1"}},
},
},
})
defer s.Close()
c := githubConnector{apiURL: s.URL, teamNameField: "slug"}
groups, err := c.userGroups(context.Background(), newClient())
expectNil(t, err)
expectEquals(t, groups, []string{
"org-1",
"org-1:team-1",
})
}
func TestUserGroupsWithTeamNameAndSlugFieldConfig(t *testing.T) {
s := newTestServer(map[string]testResponse{
"/user/orgs": {
data: []org{{Login: "org-1"}},
},
"/user/teams": {
data: []team{
{Name: "Team 1", Slug: "team-1", Org: org{Login: "org-1"}},
},
},
})
defer s.Close()
c := githubConnector{apiURL: s.URL, teamNameField: "both"}
groups, err := c.userGroups(context.Background(), newClient())
expectNil(t, err)
expectEquals(t, groups, []string{
"org-1",
"org-1:Team 1",
"org-1:team-1",
})
}
// tests that the users login is used as their username when they have no username set
func TestUsernameIncludedInFederatedIdentity(t *testing.T) {
s := newTestServer(map[string]testResponse{
"/user": {data: user{Login: "some-login", ID: 12345678}},
"/user/emails": {data: []userEmail{{
Email: "some@email.com",
Verified: true,
Primary: true,
}}},
"/login/oauth/access_token": {data: map[string]interface{}{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
"expires_in": "30",
}},
"/user/orgs": {
data: []org{{Login: "org-1"}},
},
})
defer s.Close()
hostURL, err := url.Parse(s.URL)
expectNil(t, err)
req, err := http.NewRequest("GET", hostURL.String(), nil)
expectNil(t, err)
c := githubConnector{apiURL: s.URL, hostName: hostURL.Host, httpClient: newClient()}
identity, err := c.HandleCallback(connector.Scopes{Groups: true}, req)
expectNil(t, err)
expectEquals(t, identity.Username, "some-login")
expectEquals(t, identity.UserID, "12345678")
expectEquals(t, 0, len(identity.Groups))
c = githubConnector{apiURL: s.URL, hostName: hostURL.Host, httpClient: newClient(), loadAllGroups: true}
identity, err = c.HandleCallback(connector.Scopes{Groups: true}, req)
expectNil(t, err)
expectEquals(t, identity.Username, "some-login")
expectEquals(t, identity.UserID, "12345678")
expectEquals(t, identity.Groups, []string{"org-1"})
}
func TestLoginUsedAsIDWhenConfigured(t *testing.T) {
s := newTestServer(map[string]testResponse{
"/user": {data: user{Login: "some-login", ID: 12345678, Name: "Joe Bloggs"}},
"/user/emails": {data: []userEmail{{
Email: "some@email.com",
Verified: true,
Primary: true,
}}},
"/login/oauth/access_token": {data: map[string]interface{}{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
"expires_in": "30",
}},
"/user/orgs": {
data: []org{{Login: "org-1"}},
},
})
defer s.Close()
hostURL, err := url.Parse(s.URL)
expectNil(t, err)
req, err := http.NewRequest("GET", hostURL.String(), nil)
expectNil(t, err)
c := githubConnector{apiURL: s.URL, hostName: hostURL.Host, httpClient: newClient(), useLoginAsID: true}
identity, err := c.HandleCallback(connector.Scopes{Groups: true}, req)
expectNil(t, err)
expectEquals(t, identity.UserID, "some-login")
expectEquals(t, identity.Username, "Joe Bloggs")
}
func newTestServer(responses map[string]testResponse) *httptest.Server {
var s *httptest.Server
s = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response := responses[r.RequestURI]
linkParts := make([]string, 0)
if response.nextLink != "" {
linkParts = append(linkParts, fmt.Sprintf("<%s%s>; rel=\"next\"", s.URL, response.nextLink))
}
if response.lastLink != "" {
linkParts = append(linkParts, fmt.Sprintf("<%s%s>; rel=\"last\"", s.URL, response.lastLink))
}
if len(linkParts) > 0 {
w.Header().Add("Link", strings.Join(linkParts, ", "))
}
w.Header().Add("Content-Type", "application/json")
json.NewEncoder(w).Encode(response.data)
}))
return s
}
func newClient() *http.Client {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
return &http.Client{Transport: tr}
}
func expectNil(t *testing.T, a interface{}) {
if a != nil {
t.Errorf("Expected %+v to equal nil", a)
}
}
func expectEquals(t *testing.T, a interface{}, b interface{}) {
if !reflect.DeepEqual(a, b) {
t.Errorf("Expected %+v to equal %+v", a, b)
}
}

View file

@ -6,34 +6,27 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io/ioutil"
"net/http" "net/http"
"regexp"
"strconv" "strconv"
"time"
"github.com/Sirupsen/logrus"
"github.com/coreos/dex/connector"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"github.com/dexidp/dex/connector"
"github.com/dexidp/dex/pkg/groups"
"github.com/dexidp/dex/pkg/log"
) )
const ( const (
// read operations of the /api/v4/user endpoint scopeEmail = "user:email"
scopeUser = "read_user" scopeOrgs = "read:org"
// used to retrieve groups from /oauth/userinfo
// https://docs.gitlab.com/ee/integration/openid_connect_provider.html
scopeOpenID = "openid"
) )
// Config holds configuration options for gitlab logins. // Config holds configuration options for gilab logins.
type Config struct { type Config struct {
BaseURL string `json:"baseURL"` BaseURL string `json:"baseURL"`
ClientID string `json:"clientID"` ClientID string `json:"clientID"`
ClientSecret string `json:"clientSecret"` ClientSecret string `json:"clientSecret"`
RedirectURI string `json:"redirectURI"` RedirectURI string `json:"redirectURI"`
Groups []string `json:"groups"`
UseLoginAsID bool `json:"useLoginAsID"`
} }
type gitlabUser struct { type gitlabUser struct {
@ -45,10 +38,16 @@ type gitlabUser struct {
IsAdmin bool IsAdmin bool
} }
type gitlabGroup struct {
ID int
Name string
Path string
}
// Open returns a strategy for logging in through GitLab. // Open returns a strategy for logging in through GitLab.
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) { func (c *Config) Open(logger logrus.FieldLogger) (connector.Connector, error) {
if c.BaseURL == "" { if c.BaseURL == "" {
c.BaseURL = "https://gitlab.com" c.BaseURL = "https://www.gitlab.com"
} }
return &gitlabConnector{ return &gitlabConnector{
baseURL: c.BaseURL, baseURL: c.BaseURL,
@ -56,15 +55,12 @@ func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error)
clientID: c.ClientID, clientID: c.ClientID,
clientSecret: c.ClientSecret, clientSecret: c.ClientSecret,
logger: logger, logger: logger,
groups: c.Groups,
useLoginAsID: c.UseLoginAsID,
}, nil }, nil
} }
type connectorData struct { type connectorData struct {
// Support GitLab's Access Tokens and Refresh tokens. // GitLab's OAuth2 tokens never expire. We don't need a refresh token.
AccessToken string `json:"accessToken"` AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
} }
var ( var (
@ -75,21 +71,14 @@ var (
type gitlabConnector struct { type gitlabConnector struct {
baseURL string baseURL string
redirectURI string redirectURI string
groups []string org string
clientID string clientID string
clientSecret string clientSecret string
logger log.Logger logger logrus.FieldLogger
httpClient *http.Client
// if set to true will use the user's handle rather than their numeric id as the ID
useLoginAsID bool
} }
func (c *gitlabConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config { func (c *gitlabConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config {
gitlabScopes := []string{scopeUser} gitlabScopes := []string{"api"}
if c.groupsRequired(scopes.Groups) {
gitlabScopes = []string{scopeUser, scopeOpenID}
}
gitlabEndpoint := oauth2.Endpoint{AuthURL: c.baseURL + "/oauth/authorize", TokenURL: c.baseURL + "/oauth/token"} gitlabEndpoint := oauth2.Endpoint{AuthURL: c.baseURL + "/oauth/authorize", TokenURL: c.baseURL + "/oauth/token"}
return &oauth2.Config{ return &oauth2.Config{
ClientID: c.clientID, ClientID: c.clientID,
@ -126,22 +115,13 @@ func (c *gitlabConnector) HandleCallback(s connector.Scopes, r *http.Request) (i
} }
oauth2Config := c.oauth2Config(s) oauth2Config := c.oauth2Config(s)
ctx := r.Context() ctx := r.Context()
if c.httpClient != nil {
ctx = context.WithValue(r.Context(), oauth2.HTTPClient, c.httpClient)
}
token, err := oauth2Config.Exchange(ctx, q.Get("code")) token, err := oauth2Config.Exchange(ctx, q.Get("code"))
if err != nil { if err != nil {
return identity, fmt.Errorf("gitlab: failed to get token: %v", err) return identity, fmt.Errorf("gitlab: failed to get token: %v", err)
} }
return c.identity(ctx, s, token)
}
func (c *gitlabConnector) identity(ctx context.Context, s connector.Scopes, token *oauth2.Token) (identity connector.Identity, err error) {
oauth2Config := c.oauth2Config(s)
client := oauth2Config.Client(ctx, token) client := oauth2Config.Client(ctx, token)
user, err := c.user(ctx, client) user, err := c.user(ctx, client)
@ -153,20 +133,15 @@ func (c *gitlabConnector) identity(ctx context.Context, s connector.Scopes, toke
if username == "" { if username == "" {
username = user.Email username = user.Email
} }
identity = connector.Identity{ identity = connector.Identity{
UserID: strconv.Itoa(user.ID), UserID: strconv.Itoa(user.ID),
Username: username, Username: username,
PreferredUsername: user.Username,
Email: user.Email, Email: user.Email,
EmailVerified: true, EmailVerified: true,
} }
if c.useLoginAsID {
identity.UserID = user.Username
}
if c.groupsRequired(s.Groups) { if s.Groups {
groups, err := c.getGroups(ctx, client, s.Groups, user.Username) groups, err := c.groups(ctx, client)
if err != nil { if err != nil {
return identity, fmt.Errorf("gitlab: get groups: %v", err) return identity, fmt.Errorf("gitlab: get groups: %v", err)
} }
@ -174,10 +149,10 @@ func (c *gitlabConnector) identity(ctx context.Context, s connector.Scopes, toke
} }
if s.OfflineAccess { if s.OfflineAccess {
data := connectorData{RefreshToken: token.RefreshToken, AccessToken: token.AccessToken} data := connectorData{AccessToken: token.AccessToken}
connData, err := json.Marshal(data) connData, err := json.Marshal(data)
if err != nil { if err != nil {
return identity, fmt.Errorf("gitlab: marshal connector data: %v", err) return identity, fmt.Errorf("marshal connector data: %v", err)
} }
identity.ConnectorData = connData identity.ConnectorData = connData
} }
@ -186,43 +161,36 @@ func (c *gitlabConnector) identity(ctx context.Context, s connector.Scopes, toke
} }
func (c *gitlabConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) { func (c *gitlabConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) {
if len(ident.ConnectorData) == 0 {
return ident, errors.New("no upstream access token found")
}
var data connectorData var data connectorData
if err := json.Unmarshal(ident.ConnectorData, &data); err != nil { if err := json.Unmarshal(ident.ConnectorData, &data); err != nil {
return ident, fmt.Errorf("gitlab: unmarshal connector data: %v", err) return ident, fmt.Errorf("gitlab: unmarshal access token: %v", err)
}
oauth2Config := c.oauth2Config(s)
if c.httpClient != nil {
ctx = context.WithValue(ctx, oauth2.HTTPClient, c.httpClient)
} }
switch { client := c.oauth2Config(s).Client(ctx, &oauth2.Token{AccessToken: data.AccessToken})
case data.RefreshToken != "": user, err := c.user(ctx, client)
{
t := &oauth2.Token{
RefreshToken: data.RefreshToken,
Expiry: time.Now().Add(-time.Hour),
}
token, err := oauth2Config.TokenSource(ctx, t).Token()
if err != nil { if err != nil {
return ident, fmt.Errorf("gitlab: failed to get refresh token: %v", err) return ident, fmt.Errorf("gitlab: get user: %v", err)
} }
return c.identity(ctx, s, token)
}
case data.AccessToken != "":
{
token := &oauth2.Token{
AccessToken: data.AccessToken,
}
return c.identity(ctx, s, token)
}
default:
return ident, errors.New("no refresh or access token found")
}
}
func (c *gitlabConnector) groupsRequired(groupScope bool) bool { username := user.Name
return len(c.groups) > 0 || groupScope if username == "" {
username = user.Email
}
ident.Username = username
ident.Email = user.Email
if s.Groups {
groups, err := c.groups(ctx, client)
if err != nil {
return ident, fmt.Errorf("gitlab: get groups: %v", err)
}
ident.Groups = groups
}
return ident, nil
} }
// user queries the GitLab API for profile information using the provided client. The HTTP // user queries the GitLab API for profile information using the provided client. The HTTP
@ -230,7 +198,7 @@ func (c *gitlabConnector) groupsRequired(groupScope bool) bool {
// a bearer token as part of the request. // a bearer token as part of the request.
func (c *gitlabConnector) user(ctx context.Context, client *http.Client) (gitlabUser, error) { func (c *gitlabConnector) user(ctx context.Context, client *http.Client) (gitlabUser, error) {
var u gitlabUser var u gitlabUser
req, err := http.NewRequest("GET", c.baseURL+"/api/v4/user", nil) req, err := http.NewRequest("GET", c.baseURL+"/api/v3/user", nil)
if err != nil { if err != nil {
return u, fmt.Errorf("gitlab: new req: %v", err) return u, fmt.Errorf("gitlab: new req: %v", err)
} }
@ -242,7 +210,7 @@ func (c *gitlabConnector) user(ctx context.Context, client *http.Client) (gitlab
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body) body, err := ioutil.ReadAll(resp.Body)
if err != nil { if err != nil {
return u, fmt.Errorf("gitlab: read body: %v", err) return u, fmt.Errorf("gitlab: read body: %v", err)
} }
@ -255,56 +223,66 @@ func (c *gitlabConnector) user(ctx context.Context, client *http.Client) (gitlab
return u, nil return u, nil
} }
type userInfo struct { // groups queries the GitLab API for group membership.
Groups []string
}
// userGroups queries the GitLab API for group membership.
// //
// The HTTP passed client is expected to be constructed by the golang.org/x/oauth2 package, // The HTTP passed client is expected to be constructed by the golang.org/x/oauth2 package,
// which inserts a bearer token as part of the request. // which inserts a bearer token as part of the request.
func (c *gitlabConnector) userGroups(ctx context.Context, client *http.Client) ([]string, error) { func (c *gitlabConnector) groups(ctx context.Context, client *http.Client) ([]string, error) {
req, err := http.NewRequest("GET", c.baseURL+"/oauth/userinfo", nil)
apiURL := c.baseURL + "/api/v3/groups"
reNext := regexp.MustCompile("<(.*)>; rel=\"next\"")
reLast := regexp.MustCompile("<(.*)>; rel=\"last\"")
groups := []string{}
var gitlabGroups []gitlabGroup
for {
// 100 is the maximum number for per_page that allowed by gitlab
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("gitlab: new req: %v", err) return nil, fmt.Errorf("gitlab: new req: %v", err)
} }
req = req.WithContext(ctx) req = req.WithContext(ctx)
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("gitlab: get URL %v", err) return nil, fmt.Errorf("gitlab: get groups: %v", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body) body, err := ioutil.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, fmt.Errorf("gitlab: read body: %v", err) return nil, fmt.Errorf("gitlab: read body: %v", err)
} }
return nil, fmt.Errorf("%s: %s", resp.Status, body) return nil, fmt.Errorf("%s: %s", resp.Status, body)
} }
var u userInfo
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil { if err := json.NewDecoder(resp.Body).Decode(&gitlabGroups); err != nil {
return nil, fmt.Errorf("failed to decode response: %v", err) return nil, fmt.Errorf("gitlab: unmarshal groups: %v", err)
} }
return u.Groups, nil for _, group := range gitlabGroups {
} groups = append(groups, group.Name)
}
func (c *gitlabConnector) getGroups(ctx context.Context, client *http.Client, groupScope bool, userLogin string) ([]string, error) {
gitlabGroups, err := c.userGroups(ctx, client) link := resp.Header.Get("Link")
if err != nil {
return nil, err if len(reLast.FindStringSubmatch(link)) > 1 {
} lastPageURL := reLast.FindStringSubmatch(link)[1]
if len(c.groups) > 0 { if apiURL == lastPageURL {
filteredGroups := groups.Filter(gitlabGroups, c.groups) break
if len(filteredGroups) == 0 { }
return nil, fmt.Errorf("gitlab: user %q is not in any of the required groups", userLogin) } else {
} break
return filteredGroups, nil }
} else if groupScope {
return gitlabGroups, nil if len(reNext.FindStringSubmatch(link)) > 1 {
} apiURL = reNext.FindStringSubmatch(link)[1]
} else {
return nil, nil break
}
}
return groups, nil
} }

View file

@ -1,283 +0,0 @@
package gitlab
import (
"context"
"crypto/tls"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"testing"
"github.com/dexidp/dex/connector"
)
func TestUserGroups(t *testing.T) {
s := newTestServer(map[string]interface{}{
"/oauth/userinfo": userInfo{
Groups: []string{"team-1", "team-2"},
},
})
defer s.Close()
c := gitlabConnector{baseURL: s.URL}
groups, err := c.getGroups(context.Background(), newClient(), true, "joebloggs")
expectNil(t, err)
expectEquals(t, groups, []string{
"team-1",
"team-2",
})
}
func TestUserGroupsWithFiltering(t *testing.T) {
s := newTestServer(map[string]interface{}{
"/oauth/userinfo": userInfo{
Groups: []string{"team-1", "team-2"},
},
})
defer s.Close()
c := gitlabConnector{baseURL: s.URL, groups: []string{"team-1"}}
groups, err := c.getGroups(context.Background(), newClient(), true, "joebloggs")
expectNil(t, err)
expectEquals(t, groups, []string{
"team-1",
})
}
func TestUserGroupsWithoutOrgs(t *testing.T) {
s := newTestServer(map[string]interface{}{
"/oauth/userinfo": userInfo{
Groups: []string{},
},
})
defer s.Close()
c := gitlabConnector{baseURL: s.URL}
groups, err := c.getGroups(context.Background(), newClient(), true, "joebloggs")
expectNil(t, err)
expectEquals(t, len(groups), 0)
}
// tests that the email is used as their username when they have no username set
func TestUsernameIncludedInFederatedIdentity(t *testing.T) {
s := newTestServer(map[string]interface{}{
"/api/v4/user": gitlabUser{Email: "some@email.com", ID: 12345678},
"/oauth/token": map[string]interface{}{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
"expires_in": "30",
},
"/oauth/userinfo": userInfo{
Groups: []string{"team-1"},
},
})
defer s.Close()
hostURL, err := url.Parse(s.URL)
expectNil(t, err)
req, err := http.NewRequest("GET", hostURL.String(), nil)
expectNil(t, err)
c := gitlabConnector{baseURL: s.URL, httpClient: newClient()}
identity, err := c.HandleCallback(connector.Scopes{Groups: false}, req)
expectNil(t, err)
expectEquals(t, identity.Username, "some@email.com")
expectEquals(t, identity.UserID, "12345678")
expectEquals(t, 0, len(identity.Groups))
c = gitlabConnector{baseURL: s.URL, httpClient: newClient()}
identity, err = c.HandleCallback(connector.Scopes{Groups: true}, req)
expectNil(t, err)
expectEquals(t, identity.Username, "some@email.com")
expectEquals(t, identity.UserID, "12345678")
expectEquals(t, identity.Groups, []string{"team-1"})
}
func TestLoginUsedAsIDWhenConfigured(t *testing.T) {
s := newTestServer(map[string]interface{}{
"/api/v4/user": gitlabUser{Email: "some@email.com", ID: 12345678, Name: "Joe Bloggs", Username: "joebloggs"},
"/oauth/token": map[string]interface{}{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
"expires_in": "30",
},
"/oauth/userinfo": userInfo{
Groups: []string{"team-1"},
},
})
defer s.Close()
hostURL, err := url.Parse(s.URL)
expectNil(t, err)
req, err := http.NewRequest("GET", hostURL.String(), nil)
expectNil(t, err)
c := gitlabConnector{baseURL: s.URL, httpClient: newClient(), useLoginAsID: true}
identity, err := c.HandleCallback(connector.Scopes{Groups: true}, req)
expectNil(t, err)
expectEquals(t, identity.UserID, "joebloggs")
expectEquals(t, identity.Username, "Joe Bloggs")
}
func TestLoginWithTeamWhitelisted(t *testing.T) {
s := newTestServer(map[string]interface{}{
"/api/v4/user": gitlabUser{Email: "some@email.com", ID: 12345678, Name: "Joe Bloggs"},
"/oauth/token": map[string]interface{}{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
"expires_in": "30",
},
"/oauth/userinfo": userInfo{
Groups: []string{"team-1"},
},
})
defer s.Close()
hostURL, err := url.Parse(s.URL)
expectNil(t, err)
req, err := http.NewRequest("GET", hostURL.String(), nil)
expectNil(t, err)
c := gitlabConnector{baseURL: s.URL, httpClient: newClient(), groups: []string{"team-1"}}
identity, err := c.HandleCallback(connector.Scopes{Groups: true}, req)
expectNil(t, err)
expectEquals(t, identity.UserID, "12345678")
expectEquals(t, identity.Username, "Joe Bloggs")
}
func TestLoginWithTeamNonWhitelisted(t *testing.T) {
s := newTestServer(map[string]interface{}{
"/api/v4/user": gitlabUser{Email: "some@email.com", ID: 12345678, Name: "Joe Bloggs", Username: "joebloggs"},
"/oauth/token": map[string]interface{}{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
"expires_in": "30",
},
"/oauth/userinfo": userInfo{
Groups: []string{"team-1"},
},
})
defer s.Close()
hostURL, err := url.Parse(s.URL)
expectNil(t, err)
req, err := http.NewRequest("GET", hostURL.String(), nil)
expectNil(t, err)
c := gitlabConnector{baseURL: s.URL, httpClient: newClient(), groups: []string{"team-2"}}
_, err = c.HandleCallback(connector.Scopes{Groups: true}, req)
expectNotNil(t, err, "HandleCallback error")
expectEquals(t, err.Error(), "gitlab: get groups: gitlab: user \"joebloggs\" is not in any of the required groups")
}
func TestRefresh(t *testing.T) {
s := newTestServer(map[string]interface{}{
"/api/v4/user": gitlabUser{Email: "some@email.com", ID: 12345678},
"/oauth/token": map[string]interface{}{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
"refresh_token": "oRzxVjCnohYRHEYEhZshkmakKmoyVoTjfUGC",
"expires_in": "30",
},
"/oauth/userinfo": userInfo{
Groups: []string{"team-1"},
},
})
defer s.Close()
hostURL, err := url.Parse(s.URL)
expectNil(t, err)
req, err := http.NewRequest("GET", hostURL.String(), nil)
expectNil(t, err)
c := gitlabConnector{baseURL: s.URL, httpClient: newClient()}
expectedConnectorData, err := json.Marshal(connectorData{
RefreshToken: "oRzxVjCnohYRHEYEhZshkmakKmoyVoTjfUGC",
AccessToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
})
expectNil(t, err)
identity, err := c.HandleCallback(connector.Scopes{OfflineAccess: true}, req)
expectNil(t, err)
expectEquals(t, identity.Username, "some@email.com")
expectEquals(t, identity.UserID, "12345678")
expectEquals(t, identity.ConnectorData, expectedConnectorData)
identity, err = c.Refresh(context.Background(), connector.Scopes{OfflineAccess: true}, identity)
expectNil(t, err)
expectEquals(t, identity.Username, "some@email.com")
expectEquals(t, identity.UserID, "12345678")
expectEquals(t, identity.ConnectorData, expectedConnectorData)
}
func TestRefreshWithEmptyConnectorData(t *testing.T) {
s := newTestServer(map[string]interface{}{
"/api/v4/user": gitlabUser{Email: "some@email.com", ID: 12345678},
"/oauth/token": map[string]interface{}{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
"refresh_token": "oRzxVjCnohYRHEYEhZshkmakKmoyVoTjfUGC",
"expires_in": "30",
},
"/oauth/userinfo": userInfo{
Groups: []string{"team-1"},
},
})
defer s.Close()
emptyConnectorData, err := json.Marshal(connectorData{
RefreshToken: "",
AccessToken: "",
})
expectNil(t, err)
c := gitlabConnector{baseURL: s.URL, httpClient: newClient()}
emptyIdentity := connector.Identity{ConnectorData: emptyConnectorData}
identity, err := c.Refresh(context.Background(), connector.Scopes{OfflineAccess: true}, emptyIdentity)
expectNotNil(t, err, "Refresh error")
expectEquals(t, emptyIdentity, identity)
}
func newTestServer(responses map[string]interface{}) *httptest.Server {
return httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response := responses[r.RequestURI]
w.Header().Add("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}))
}
func newClient() *http.Client {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
return &http.Client{Transport: tr}
}
func expectNil(t *testing.T, a interface{}) {
if a != nil {
t.Errorf("Expected %+v to equal nil", a)
}
}
func expectNotNil(t *testing.T, a interface{}, msg string) {
if a == nil {
t.Errorf("Expected %+v to not to be nil", msg)
}
}
func expectEquals(t *testing.T, a interface{}, b interface{}) {
if !reflect.DeepEqual(a, b) {
t.Errorf("Expected %+v to equal %+v", a, b)
}
}

View file

@ -1,326 +0,0 @@
// Package google implements logging in through Google's OpenID Connect provider.
package google
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
admin "google.golang.org/api/admin/directory/v1"
"google.golang.org/api/option"
"github.com/dexidp/dex/connector"
pkg_groups "github.com/dexidp/dex/pkg/groups"
"github.com/dexidp/dex/pkg/log"
)
const (
issuerURL = "https://accounts.google.com"
)
// Config holds configuration options for Google logins.
type Config struct {
ClientID string `json:"clientID"`
ClientSecret string `json:"clientSecret"`
RedirectURI string `json:"redirectURI"`
Scopes []string `json:"scopes"` // defaults to "profile" and "email"
// Optional list of whitelisted domains
// If this field is nonempty, only users from a listed domain will be allowed to log in
HostedDomains []string `json:"hostedDomains"`
// Optional list of whitelisted groups
// If this field is nonempty, only users from a listed group will be allowed to log in
Groups []string `json:"groups"`
// Optional path to service account json
// If nonempty, and groups claim is made, will use authentication from file to
// check groups with the admin directory api
ServiceAccountFilePath string `json:"serviceAccountFilePath"`
// Required if ServiceAccountFilePath
// The email of a GSuite super user which the service account will impersonate
// when listing groups
AdminEmail string
// If this field is true, fetch direct group membership and transitive group membership
FetchTransitiveGroupMembership bool `json:"fetchTransitiveGroupMembership"`
}
// Open returns a connector which can be used to login users through Google.
func (c *Config) Open(id string, logger log.Logger) (conn connector.Connector, err error) {
ctx, cancel := context.WithCancel(context.Background())
provider, err := oidc.NewProvider(ctx, issuerURL)
if err != nil {
cancel()
return nil, fmt.Errorf("failed to get provider: %v", err)
}
scopes := []string{oidc.ScopeOpenID}
if len(c.Scopes) > 0 {
scopes = append(scopes, c.Scopes...)
} else {
scopes = append(scopes, "profile", "email")
}
srv, err := createDirectoryService(c.ServiceAccountFilePath, c.AdminEmail)
if err != nil {
cancel()
return nil, fmt.Errorf("could not create directory service: %v", err)
}
clientID := c.ClientID
return &googleConnector{
redirectURI: c.RedirectURI,
oauth2Config: &oauth2.Config{
ClientID: clientID,
ClientSecret: c.ClientSecret,
Endpoint: provider.Endpoint(),
Scopes: scopes,
RedirectURL: c.RedirectURI,
},
verifier: provider.Verifier(
&oidc.Config{ClientID: clientID},
),
logger: logger,
cancel: cancel,
hostedDomains: c.HostedDomains,
groups: c.Groups,
serviceAccountFilePath: c.ServiceAccountFilePath,
adminEmail: c.AdminEmail,
fetchTransitiveGroupMembership: c.FetchTransitiveGroupMembership,
adminSrv: srv,
}, nil
}
var (
_ connector.CallbackConnector = (*googleConnector)(nil)
_ connector.RefreshConnector = (*googleConnector)(nil)
)
type googleConnector struct {
redirectURI string
oauth2Config *oauth2.Config
verifier *oidc.IDTokenVerifier
cancel context.CancelFunc
logger log.Logger
hostedDomains []string
groups []string
serviceAccountFilePath string
adminEmail string
fetchTransitiveGroupMembership bool
adminSrv *admin.Service
}
func (c *googleConnector) Close() error {
c.cancel()
return nil
}
func (c *googleConnector) LoginURL(s connector.Scopes, callbackURL, state string) (string, error) {
if c.redirectURI != callbackURL {
return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI)
}
var opts []oauth2.AuthCodeOption
if len(c.hostedDomains) > 0 {
preferredDomain := c.hostedDomains[0]
if len(c.hostedDomains) > 1 {
preferredDomain = "*"
}
opts = append(opts, oauth2.SetAuthURLParam("hd", preferredDomain))
}
if s.OfflineAccess {
opts = append(opts, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent"))
}
return c.oauth2Config.AuthCodeURL(state, opts...), nil
}
type oauth2Error struct {
error string
errorDescription string
}
func (e *oauth2Error) Error() string {
if e.errorDescription == "" {
return e.error
}
return e.error + ": " + e.errorDescription
}
func (c *googleConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) {
q := r.URL.Query()
if errType := q.Get("error"); errType != "" {
return identity, &oauth2Error{errType, q.Get("error_description")}
}
token, err := c.oauth2Config.Exchange(r.Context(), q.Get("code"))
if err != nil {
return identity, fmt.Errorf("google: failed to get token: %v", err)
}
return c.createIdentity(r.Context(), identity, s, token)
}
func (c *googleConnector) Refresh(ctx context.Context, s connector.Scopes, identity connector.Identity) (connector.Identity, error) {
t := &oauth2.Token{
RefreshToken: string(identity.ConnectorData),
Expiry: time.Now().Add(-time.Hour),
}
token, err := c.oauth2Config.TokenSource(ctx, t).Token()
if err != nil {
return identity, fmt.Errorf("google: failed to get token: %v", err)
}
return c.createIdentity(ctx, identity, s, token)
}
func (c *googleConnector) createIdentity(ctx context.Context, identity connector.Identity, s connector.Scopes, token *oauth2.Token) (connector.Identity, error) {
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return identity, errors.New("google: no id_token in token response")
}
idToken, err := c.verifier.Verify(ctx, rawIDToken)
if err != nil {
return identity, fmt.Errorf("google: failed to verify ID Token: %v", err)
}
var claims struct {
Username string `json:"name"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
HostedDomain string `json:"hd"`
}
if err := idToken.Claims(&claims); err != nil {
return identity, fmt.Errorf("oidc: failed to decode claims: %v", err)
}
if len(c.hostedDomains) > 0 {
found := false
for _, domain := range c.hostedDomains {
if claims.HostedDomain == domain {
found = true
break
}
}
if !found {
return identity, fmt.Errorf("oidc: unexpected hd claim %v", claims.HostedDomain)
}
}
var groups []string
if s.Groups && c.adminSrv != nil {
groups, err = c.getGroups(claims.Email, c.fetchTransitiveGroupMembership)
if err != nil {
return identity, fmt.Errorf("google: could not retrieve groups: %v", err)
}
if len(c.groups) > 0 {
groups = pkg_groups.Filter(groups, c.groups)
if len(groups) == 0 {
return identity, fmt.Errorf("google: user %q is not in any of the required groups", claims.Username)
}
}
}
identity = connector.Identity{
UserID: idToken.Subject,
Username: claims.Username,
Email: claims.Email,
EmailVerified: claims.EmailVerified,
ConnectorData: []byte(token.RefreshToken),
Groups: groups,
}
return identity, nil
}
// getGroups creates a connection to the admin directory service and lists
// all groups the user is a member of
func (c *googleConnector) getGroups(email string, fetchTransitiveGroupMembership bool) ([]string, error) {
var userGroups []string
var err error
groupsList := &admin.Groups{}
for {
groupsList, err = c.adminSrv.Groups.List().
UserKey(email).PageToken(groupsList.NextPageToken).Do()
if err != nil {
return nil, fmt.Errorf("could not list groups: %v", err)
}
for _, group := range groupsList.Groups {
// TODO (joelspeed): Make desired group key configurable
userGroups = append(userGroups, group.Email)
// getGroups takes a user's email/alias as well as a group's email/alias
if fetchTransitiveGroupMembership {
transitiveGroups, err := c.getGroups(group.Email, fetchTransitiveGroupMembership)
if err != nil {
return nil, fmt.Errorf("could not list transitive groups: %v", err)
}
userGroups = append(userGroups, transitiveGroups...)
}
}
if groupsList.NextPageToken == "" {
break
}
}
return uniqueGroups(userGroups), nil
}
// createDirectoryService loads a google service account credentials file,
// sets up super user impersonation and creates an admin client for calling
// the google admin api
func createDirectoryService(serviceAccountFilePath string, email string) (*admin.Service, error) {
if serviceAccountFilePath == "" && email == "" {
return nil, nil
}
if serviceAccountFilePath == "" || email == "" {
return nil, fmt.Errorf("directory service requires both serviceAccountFilePath and adminEmail")
}
jsonCredentials, err := os.ReadFile(serviceAccountFilePath)
if err != nil {
return nil, fmt.Errorf("error reading credentials from file: %v", err)
}
config, err := google.JWTConfigFromJSON(jsonCredentials, admin.AdminDirectoryGroupReadonlyScope)
if err != nil {
return nil, fmt.Errorf("unable to parse client secret file to config: %v", err)
}
// Impersonate an admin. This is mandatory for the admin APIs.
config.Subject = email
ctx := context.Background()
client := config.Client(ctx)
srv, err := admin.NewService(ctx, option.WithHTTPClient(client))
if err != nil {
return nil, fmt.Errorf("unable to create directory service %v", err)
}
return srv, nil
}
// uniqueGroups returns the unique groups of a slice
func uniqueGroups(groups []string) []string {
keys := make(map[string]struct{})
unique := []string{}
for _, group := range groups {
if _, exists := keys[group]; !exists {
keys[group] = struct{}{}
unique = append(unique, group)
}
}
return unique
}

View file

@ -1,312 +0,0 @@
// Package keystone provides authentication strategy using Keystone.
package keystone
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/dexidp/dex/connector"
"github.com/dexidp/dex/pkg/log"
)
type conn struct {
Domain string
Host string
AdminUsername string
AdminPassword string
Logger log.Logger
}
type userKeystone struct {
Domain domainKeystone `json:"domain"`
ID string `json:"id"`
Name string `json:"name"`
}
type domainKeystone struct {
ID string `json:"id"`
Name string `json:"name"`
}
// Config holds the configuration parameters for Keystone connector.
// Keystone should expose API v3
// An example config:
// connectors:
// type: keystone
// id: keystone
// name: Keystone
// config:
// keystoneHost: http://example:5000
// domain: default
// keystoneUsername: demo
// keystonePassword: DEMO_PASS
type Config struct {
Domain string `json:"domain"`
Host string `json:"keystoneHost"`
AdminUsername string `json:"keystoneUsername"`
AdminPassword string `json:"keystonePassword"`
}
type loginRequestData struct {
auth `json:"auth"`
}
type auth struct {
Identity identity `json:"identity"`
}
type identity struct {
Methods []string `json:"methods"`
Password password `json:"password"`
}
type password struct {
User user `json:"user"`
}
type user struct {
Name string `json:"name"`
Domain domain `json:"domain"`
Password string `json:"password"`
}
type domain struct {
ID string `json:"id"`
}
type token struct {
User userKeystone `json:"user"`
}
type tokenResponse struct {
Token token `json:"token"`
}
type group struct {
ID string `json:"id"`
Name string `json:"name"`
}
type groupsResponse struct {
Groups []group `json:"groups"`
}
type userResponse struct {
User struct {
Name string `json:"name"`
Email string `json:"email"`
ID string `json:"id"`
} `json:"user"`
}
var (
_ connector.PasswordConnector = &conn{}
_ connector.RefreshConnector = &conn{}
)
// Open returns an authentication strategy using Keystone.
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
return &conn{
c.Domain,
c.Host,
c.AdminUsername,
c.AdminPassword,
logger,
}, nil
}
func (p *conn) Close() error { return nil }
func (p *conn) Login(ctx context.Context, scopes connector.Scopes, username, password string) (identity connector.Identity, validPassword bool, err error) {
resp, err := p.getTokenResponse(ctx, username, password)
if err != nil {
return identity, false, fmt.Errorf("keystone: error %v", err)
}
if resp.StatusCode/100 != 2 {
return identity, false, fmt.Errorf("keystone login: error %v", resp.StatusCode)
}
if resp.StatusCode != 201 {
return identity, false, nil
}
token := resp.Header.Get("X-Subject-Token")
data, err := io.ReadAll(resp.Body)
if err != nil {
return identity, false, err
}
defer resp.Body.Close()
tokenResp := new(tokenResponse)
err = json.Unmarshal(data, &tokenResp)
if err != nil {
return identity, false, fmt.Errorf("keystone: invalid token response: %v", err)
}
if scopes.Groups {
groups, err := p.getUserGroups(ctx, tokenResp.Token.User.ID, token)
if err != nil {
return identity, false, err
}
identity.Groups = groups
}
identity.Username = username
identity.UserID = tokenResp.Token.User.ID
user, err := p.getUser(ctx, tokenResp.Token.User.ID, token)
if err != nil {
return identity, false, err
}
if user.User.Email != "" {
identity.Email = user.User.Email
identity.EmailVerified = true
}
return identity, true, nil
}
func (p *conn) Prompt() string { return "username" }
func (p *conn) Refresh(
ctx context.Context, scopes connector.Scopes, identity connector.Identity,
) (connector.Identity, error) {
token, err := p.getAdminToken(ctx)
if err != nil {
return identity, fmt.Errorf("keystone: failed to obtain admin token: %v", err)
}
ok, err := p.checkIfUserExists(ctx, identity.UserID, token)
if err != nil {
return identity, err
}
if !ok {
return identity, fmt.Errorf("keystone: user %q does not exist", identity.UserID)
}
if scopes.Groups {
groups, err := p.getUserGroups(ctx, identity.UserID, token)
if err != nil {
return identity, err
}
identity.Groups = groups
}
return identity, nil
}
func (p *conn) getTokenResponse(ctx context.Context, username, pass string) (response *http.Response, err error) {
client := &http.Client{}
jsonData := loginRequestData{
auth: auth{
Identity: identity{
Methods: []string{"password"},
Password: password{
User: user{
Name: username,
Domain: domain{ID: p.Domain},
Password: pass,
},
},
},
},
}
jsonValue, err := json.Marshal(jsonData)
if err != nil {
return nil, err
}
// https://developer.openstack.org/api-ref/identity/v3/#password-authentication-with-unscoped-authorization
authTokenURL := p.Host + "/v3/auth/tokens/"
req, err := http.NewRequest("POST", authTokenURL, bytes.NewBuffer(jsonValue))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
return client.Do(req)
}
func (p *conn) getAdminToken(ctx context.Context) (string, error) {
resp, err := p.getTokenResponse(ctx, p.AdminUsername, p.AdminPassword)
if err != nil {
return "", err
}
defer resp.Body.Close()
token := resp.Header.Get("X-Subject-Token")
return token, nil
}
func (p *conn) checkIfUserExists(ctx context.Context, userID string, token string) (bool, error) {
user, err := p.getUser(ctx, userID, token)
return user != nil, err
}
func (p *conn) getUser(ctx context.Context, userID string, token string) (*userResponse, error) {
// https://developer.openstack.org/api-ref/identity/v3/#show-user-details
userURL := p.Host + "/v3/users/" + userID
client := &http.Client{}
req, err := http.NewRequest("GET", userURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Auth-Token", token)
req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, err
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
user := userResponse{}
err = json.Unmarshal(data, &user)
if err != nil {
return nil, err
}
return &user, nil
}
func (p *conn) getUserGroups(ctx context.Context, userID string, token string) ([]string, error) {
client := &http.Client{}
// https://developer.openstack.org/api-ref/identity/v3/#list-groups-to-which-a-user-belongs
groupsURL := p.Host + "/v3/users/" + userID + "/groups"
req, err := http.NewRequest("GET", groupsURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Auth-Token", token)
req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil {
p.Logger.Errorf("keystone: error while fetching user %q groups\n", userID)
return nil, err
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
defer resp.Body.Close()
groupsResp := new(groupsResponse)
err = json.Unmarshal(data, &groupsResp)
if err != nil {
return nil, err
}
groups := make([]string, len(groupsResp.Groups))
for i, group := range groupsResp.Groups {
groups[i] = group.Name
}
return groups, nil
}

View file

@ -1,483 +0,0 @@
package keystone
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"os"
"reflect"
"strings"
"testing"
"github.com/dexidp/dex/connector"
)
const (
invalidPass = "WRONG_PASS"
testUser = "test_user"
testPass = "test_pass"
testEmail = "test@example.com"
testGroup = "test_group"
testDomain = "default"
)
var (
keystoneURL = ""
keystoneAdminURL = ""
adminUser = ""
adminPass = ""
authTokenURL = ""
usersURL = ""
groupsURL = ""
)
type groupResponse struct {
Group struct {
ID string `json:"id"`
} `json:"group"`
}
func getAdminToken(t *testing.T, adminName, adminPass string) (token, id string) {
t.Helper()
client := &http.Client{}
jsonData := loginRequestData{
auth: auth{
Identity: identity{
Methods: []string{"password"},
Password: password{
User: user{
Name: adminName,
Domain: domain{ID: testDomain},
Password: adminPass,
},
},
},
},
}
body, err := json.Marshal(jsonData)
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequest("POST", authTokenURL, bytes.NewBuffer(body))
if err != nil {
t.Fatalf("keystone: failed to obtain admin token: %v\n", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
token = resp.Header.Get("X-Subject-Token")
data, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
tokenResp := new(tokenResponse)
err = json.Unmarshal(data, &tokenResp)
if err != nil {
t.Fatal(err)
}
return token, tokenResp.Token.User.ID
}
func createUser(t *testing.T, token, userName, userEmail, userPass string) string {
t.Helper()
client := &http.Client{}
createUserData := map[string]interface{}{
"user": map[string]interface{}{
"name": userName,
"email": userEmail,
"enabled": true,
"password": userPass,
"roles": []string{"admin"},
},
}
body, err := json.Marshal(createUserData)
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequest("POST", usersURL, bytes.NewBuffer(body))
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-Auth-Token", token)
req.Header.Add("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
userResp := new(userResponse)
err = json.Unmarshal(data, &userResp)
if err != nil {
t.Fatal(err)
}
return userResp.User.ID
}
// delete group or user
func deleteResource(t *testing.T, token, id, uri string) {
t.Helper()
client := &http.Client{}
deleteURI := uri + id
req, err := http.NewRequest("DELETE", deleteURI, nil)
if err != nil {
t.Fatalf("error: %v", err)
}
req.Header.Set("X-Auth-Token", token)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("error: %v", err)
}
defer resp.Body.Close()
}
func createGroup(t *testing.T, token, description, name string) string {
t.Helper()
client := &http.Client{}
createGroupData := map[string]interface{}{
"group": map[string]interface{}{
"name": name,
"description": description,
},
}
body, err := json.Marshal(createGroupData)
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequest("POST", groupsURL, bytes.NewBuffer(body))
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-Auth-Token", token)
req.Header.Add("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
groupResp := new(groupResponse)
err = json.Unmarshal(data, &groupResp)
if err != nil {
t.Fatal(err)
}
return groupResp.Group.ID
}
func addUserToGroup(t *testing.T, token, groupID, userID string) error {
t.Helper()
uri := groupsURL + groupID + "/users/" + userID
client := &http.Client{}
req, err := http.NewRequest("PUT", uri, nil)
if err != nil {
return err
}
req.Header.Set("X-Auth-Token", token)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("error: %v", err)
}
defer resp.Body.Close()
return nil
}
func TestIncorrectCredentialsLogin(t *testing.T) {
setupVariables(t)
c := conn{
Host: keystoneURL, Domain: testDomain,
AdminUsername: adminUser, AdminPassword: adminPass,
}
s := connector.Scopes{OfflineAccess: true, Groups: true}
_, validPW, err := c.Login(context.Background(), s, adminUser, invalidPass)
if validPW {
t.Fatal("Incorrect password check")
}
if err == nil {
t.Fatal("Error should be returned when invalid password is provided")
}
if !strings.Contains(err.Error(), "401") {
t.Fatal("Unrecognized error, expecting 401")
}
}
func TestValidUserLogin(t *testing.T) {
setupVariables(t)
token, _ := getAdminToken(t, adminUser, adminPass)
type tUser struct {
username string
domain string
email string
password string
}
type expect struct {
username string
email string
verifiedEmail bool
}
tests := []struct {
name string
input tUser
expected expect
}{
{
name: "test with email address",
input: tUser{
username: testUser,
domain: testDomain,
email: testEmail,
password: testPass,
},
expected: expect{
username: testUser,
email: testEmail,
verifiedEmail: true,
},
},
{
name: "test without email address",
input: tUser{
username: testUser,
domain: testDomain,
email: "",
password: testPass,
},
expected: expect{
username: testUser,
email: "",
verifiedEmail: false,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
userID := createUser(t, token, tt.input.username, tt.input.email, tt.input.password)
defer deleteResource(t, token, userID, usersURL)
c := conn{
Host: keystoneURL, Domain: tt.input.domain,
AdminUsername: adminUser, AdminPassword: adminPass,
}
s := connector.Scopes{OfflineAccess: true, Groups: true}
identity, validPW, err := c.Login(context.Background(), s, tt.input.username, tt.input.password)
if err != nil {
t.Fatal(err.Error())
}
t.Log(identity)
if identity.Username != tt.expected.username {
t.Fatalf("Invalid user. Got: %v. Wanted: %v", identity.Username, tt.expected.username)
}
if identity.UserID == "" {
t.Fatalf("Didn't get any UserID back")
}
if identity.Email != tt.expected.email {
t.Fatalf("Invalid email. Got: %v. Wanted: %v", identity.Email, tt.expected.email)
}
if identity.EmailVerified != tt.expected.verifiedEmail {
t.Fatalf("Invalid verifiedEmail. Got: %v. Wanted: %v", identity.EmailVerified, tt.expected.verifiedEmail)
}
if !validPW {
t.Fatal("Valid password was not accepted")
}
})
}
}
func TestUseRefreshToken(t *testing.T) {
setupVariables(t)
token, adminID := getAdminToken(t, adminUser, adminPass)
groupID := createGroup(t, token, "Test group description", testGroup)
addUserToGroup(t, token, groupID, adminID)
defer deleteResource(t, token, groupID, groupsURL)
c := conn{
Host: keystoneURL, Domain: testDomain,
AdminUsername: adminUser, AdminPassword: adminPass,
}
s := connector.Scopes{OfflineAccess: true, Groups: true}
identityLogin, _, err := c.Login(context.Background(), s, adminUser, adminPass)
if err != nil {
t.Fatal(err.Error())
}
identityRefresh, err := c.Refresh(context.Background(), s, identityLogin)
if err != nil {
t.Fatal(err.Error())
}
expectEquals(t, 1, len(identityRefresh.Groups))
expectEquals(t, testGroup, identityRefresh.Groups[0])
}
func TestUseRefreshTokenUserDeleted(t *testing.T) {
setupVariables(t)
token, _ := getAdminToken(t, adminUser, adminPass)
userID := createUser(t, token, testUser, testEmail, testPass)
c := conn{
Host: keystoneURL, Domain: testDomain,
AdminUsername: adminUser, AdminPassword: adminPass,
}
s := connector.Scopes{OfflineAccess: true, Groups: true}
identityLogin, _, err := c.Login(context.Background(), s, testUser, testPass)
if err != nil {
t.Fatal(err.Error())
}
_, err = c.Refresh(context.Background(), s, identityLogin)
if err != nil {
t.Fatal(err.Error())
}
deleteResource(t, token, userID, usersURL)
_, err = c.Refresh(context.Background(), s, identityLogin)
if !strings.Contains(err.Error(), "does not exist") {
t.Errorf("unexpected error: %s", err.Error())
}
}
func TestUseRefreshTokenGroupsChanged(t *testing.T) {
setupVariables(t)
token, _ := getAdminToken(t, adminUser, adminPass)
userID := createUser(t, token, testUser, testEmail, testPass)
defer deleteResource(t, token, userID, usersURL)
c := conn{
Host: keystoneURL, Domain: testDomain,
AdminUsername: adminUser, AdminPassword: adminPass,
}
s := connector.Scopes{OfflineAccess: true, Groups: true}
identityLogin, _, err := c.Login(context.Background(), s, testUser, testPass)
if err != nil {
t.Fatal(err.Error())
}
identityRefresh, err := c.Refresh(context.Background(), s, identityLogin)
if err != nil {
t.Fatal(err.Error())
}
expectEquals(t, 0, len(identityRefresh.Groups))
groupID := createGroup(t, token, "Test group", testGroup)
addUserToGroup(t, token, groupID, userID)
defer deleteResource(t, token, groupID, groupsURL)
identityRefresh, err = c.Refresh(context.Background(), s, identityLogin)
if err != nil {
t.Fatal(err.Error())
}
expectEquals(t, 1, len(identityRefresh.Groups))
}
func TestNoGroupsInScope(t *testing.T) {
setupVariables(t)
token, _ := getAdminToken(t, adminUser, adminPass)
userID := createUser(t, token, testUser, testEmail, testPass)
defer deleteResource(t, token, userID, usersURL)
c := conn{
Host: keystoneURL, Domain: testDomain,
AdminUsername: adminUser, AdminPassword: adminPass,
}
s := connector.Scopes{OfflineAccess: true, Groups: false}
groupID := createGroup(t, token, "Test group", testGroup)
addUserToGroup(t, token, groupID, userID)
defer deleteResource(t, token, groupID, groupsURL)
identityLogin, _, err := c.Login(context.Background(), s, testUser, testPass)
if err != nil {
t.Fatal(err.Error())
}
expectEquals(t, 0, len(identityLogin.Groups))
identityRefresh, err := c.Refresh(context.Background(), s, identityLogin)
if err != nil {
t.Fatal(err.Error())
}
expectEquals(t, 0, len(identityRefresh.Groups))
}
func setupVariables(t *testing.T) {
keystoneURLEnv := "DEX_KEYSTONE_URL"
keystoneAdminURLEnv := "DEX_KEYSTONE_ADMIN_URL"
keystoneAdminUserEnv := "DEX_KEYSTONE_ADMIN_USER"
keystoneAdminPassEnv := "DEX_KEYSTONE_ADMIN_PASS"
keystoneURL = os.Getenv(keystoneURLEnv)
if keystoneURL == "" {
t.Skipf("variable %q not set, skipping keystone connector tests\n", keystoneURLEnv)
return
}
keystoneAdminURL = os.Getenv(keystoneAdminURLEnv)
if keystoneAdminURL == "" {
t.Skipf("variable %q not set, skipping keystone connector tests\n", keystoneAdminURLEnv)
return
}
adminUser = os.Getenv(keystoneAdminUserEnv)
if adminUser == "" {
t.Skipf("variable %q not set, skipping keystone connector tests\n", keystoneAdminUserEnv)
return
}
adminPass = os.Getenv(keystoneAdminPassEnv)
if adminPass == "" {
t.Skipf("variable %q not set, skipping keystone connector tests\n", keystoneAdminPassEnv)
return
}
authTokenURL = keystoneURL + "/v3/auth/tokens/"
usersURL = keystoneAdminURL + "/v3/users/"
groupsURL = keystoneAdminURL + "/v3/groups/"
}
func expectEquals(t *testing.T, a interface{}, b interface{}) {
if !reflect.DeepEqual(a, b) {
t.Errorf("Expected %v to be equal %v", a, b)
}
}

View file

@ -1,49 +0,0 @@
#!/bin/bash -e
# Stolen from the coreos/matchbox repo.
echo "
[req]
req_extensions = v3_req
distinguished_name = req_distinguished_name
[req_distinguished_name]
[ v3_req ]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName = @alt_names
[alt_names]
DNS.101 = localhost
" > openssl.config
openssl genrsa -out testdata/ca.key 2048
openssl genrsa -out testdata/server.key 2048
openssl req \
-x509 -new -nodes \
-key testdata/ca.key \
-days 10000 -out testdata/ca.crt \
-subj "/CN=ldap-tests"
openssl req \
-new \
-key testdata/server.key \
-out testdata/server.csr \
-subj "/CN=localhost" \
-config openssl.config
openssl x509 -req \
-in testdata/server.csr \
-CA testdata/ca.crt \
-CAkey testdata/ca.key \
-CAcreateserial \
-out testdata/server.crt \
-days 10000 \
-extensions v3_req \
-extfile openssl.config
rm testdata/server.csr
rm testdata/ca.srl
rm openssl.config

View file

@ -7,13 +7,13 @@ import (
"crypto/x509" "crypto/x509"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/ioutil"
"net" "net"
"os"
"github.com/go-ldap/ldap/v3" "gopkg.in/ldap.v2"
"github.com/dexidp/dex/connector" "github.com/Sirupsen/logrus"
"github.com/dexidp/dex/pkg/log" "github.com/coreos/dex/connector"
) )
// Config holds the configuration parameters for the LDAP connector. The LDAP // Config holds the configuration parameters for the LDAP connector. The LDAP
@ -29,7 +29,7 @@ import (
// # The following field is required if using port 389. // # The following field is required if using port 389.
// # insecureNoSSL: true // # insecureNoSSL: true
// rootCA: /etc/dex/ldap.ca // rootCA: /etc/dex/ldap.ca
// bindDN: uid=serviceaccount,cn=users,dc=example,dc=com // bindDN: uid=seviceaccount,cn=users,dc=example,dc=com
// bindPW: password // bindPW: password
// userSearch: // userSearch:
// # Would translate to the query "(&(objectClass=person)(uid=<username>))" // # Would translate to the query "(&(objectClass=person)(uid=<username>))"
@ -39,30 +39,17 @@ import (
// idAttr: uid // idAttr: uid
// emailAttr: mail // emailAttr: mail
// nameAttr: name // nameAttr: name
// preferredUsernameAttr: uid
// groupSearch: // groupSearch:
// # Would translate to the separate query per user matcher pair and aggregate results into a single group list: // # Would translate to the query "(&(objectClass=group)(member=<user uid>))"
// # "(&(|(objectClass=posixGroup)(objectClass=groupOfNames))(memberUid=<user uid>))"
// # "(&(|(objectClass=posixGroup)(objectClass=groupOfNames))(member=<user DN>))"
// baseDN: cn=groups,dc=example,dc=com // baseDN: cn=groups,dc=example,dc=com
// filter: "(|(objectClass=posixGroup)(objectClass=groupOfNames))" // filter: "(objectClass=group)"
// userMatchers: // userAttr: uid
// - userAttr: uid
// groupAttr: memberUid
// # Use if full DN is needed and not available as any other attribute // # Use if full DN is needed and not available as any other attribute
// # Will only work if "DN" attribute does not exist in the record: // # Will only work if "DN" attribute does not exist in the record
// - userAttr: DN // # userAttr: DN
// groupAttr: member // groupAttr: member
// nameAttr: name // nameAttr: name
// //
// UserMatcher holds information about user and group matching.
type UserMatcher struct {
UserAttr string `json:"userAttr"`
GroupAttr string `json:"groupAttr"`
}
// Config holds configuration options for LDAP logins.
type Config struct { type Config struct {
// The host and optional port of the LDAP server. If port isn't supplied, it will be // The host and optional port of the LDAP server. If port isn't supplied, it will be
// guessed based on the TLS configuration. 389 or 636. // guessed based on the TLS configuration. 389 or 636.
@ -74,17 +61,9 @@ type Config struct {
// Don't verify the CA. // Don't verify the CA.
InsecureSkipVerify bool `json:"insecureSkipVerify"` InsecureSkipVerify bool `json:"insecureSkipVerify"`
// Connect to the insecure port then issue a StartTLS command to negotiate a
// secure connection. If unsupplied secure connections will use the LDAPS
// protocol.
StartTLS bool `json:"startTLS"`
// Path to a trusted root certificate file. // Path to a trusted root certificate file.
RootCA string `json:"rootCA"` RootCA string `json:"rootCA"`
// Path to a client cert file generated by rootCA.
ClientCert string `json:"clientCert"`
// Path to a client private key file generated by rootCA.
ClientKey string `json:"clientKey"`
// Base64 encoded PEM data containing root CAs. // Base64 encoded PEM data containing root CAs.
RootCAData []byte `json:"rootCAData"` RootCAData []byte `json:"rootCAData"`
@ -93,14 +72,9 @@ type Config struct {
BindDN string `json:"bindDN"` BindDN string `json:"bindDN"`
BindPW string `json:"bindPW"` BindPW string `json:"bindPW"`
// UsernamePrompt allows users to override the username attribute (displayed
// in the username/password prompt). If unset, the handler will use
// "Username".
UsernamePrompt string `json:"usernamePrompt"`
// User entry search configuration. // User entry search configuration.
UserSearch struct { UserSearch struct {
// BaseDN to start the search from. For example "cn=users,dc=example,dc=com" // BsaeDN to start the search from. For example "cn=users,dc=example,dc=com"
BaseDN string `json:"baseDN"` BaseDN string `json:"baseDN"`
// Optional filter to apply when searching the directory. For example "(objectClass=person)" // Optional filter to apply when searching the directory. For example "(objectClass=person)"
@ -119,16 +93,12 @@ type Config struct {
IDAttr string `json:"idAttr"` // Defaults to "uid" IDAttr string `json:"idAttr"` // Defaults to "uid"
EmailAttr string `json:"emailAttr"` // Defaults to "mail" EmailAttr string `json:"emailAttr"` // Defaults to "mail"
NameAttr string `json:"nameAttr"` // No default. NameAttr string `json:"nameAttr"` // No default.
PreferredUsernameAttrAttr string `json:"preferredUsernameAttr"` // No default.
// If this is set, the email claim of the id token will be constructed from the idAttr and
// value of emailSuffix. This should not include the @ character.
EmailSuffix string `json:"emailSuffix"` // No default.
} `json:"userSearch"` } `json:"userSearch"`
// Group search configuration. // Group search configuration.
GroupSearch struct { GroupSearch struct {
// BaseDN to start the search from. For example "cn=groups,dc=example,dc=com" // BsaeDN to start the search from. For example "cn=groups,dc=example,dc=com"
BaseDN string `json:"baseDN"` BaseDN string `json:"baseDN"`
// Optional filter to apply when searching the directory. For example "(objectClass=posixGroup)" // Optional filter to apply when searching the directory. For example "(objectClass=posixGroup)"
@ -136,41 +106,22 @@ type Config struct {
Scope string `json:"scope"` // Defaults to "sub" Scope string `json:"scope"` // Defaults to "sub"
// DEPRECATED config options. Those are left for backward compatibility. // These two fields are use to match a user to a group.
// See "UserMatchers" below for the current group to user matching implementation
// TODO: should be eventually removed from the code
UserAttr string `json:"userAttr"`
GroupAttr string `json:"groupAttr"`
// Array of the field pairs used to match a user to a group.
// See the "UserMatcher" struct for the exact field names
// //
// Each pair adds an additional requirement to the filter that an attribute in the group // It adds an additional requirement to the filter that an attribute in the group
// match the user's attribute value. For example that the "members" attribute of // match the user's attribute value. For example that the "members" attribute of
// a group matches the "uid" of the user. The exact filter being added is: // a group matches the "uid" of the user. The exact filter being added is:
// //
// (userMatchers[n].<groupAttr>=userMatchers[n].<userAttr value>) // (<groupAttr>=<userAttr value>)
// //
UserMatchers []UserMatcher `json:"userMatchers"` UserAttr string `json:"userAttr"`
GroupAttr string `json:"groupAttr"`
// The attribute of the group that represents its name. // The attribute of the group that represents its name.
NameAttr string `json:"nameAttr"` NameAttr string `json:"nameAttr"`
} `json:"groupSearch"` } `json:"groupSearch"`
} }
func scopeString(i int) string {
switch i {
case ldap.ScopeBaseObject:
return "base"
case ldap.ScopeSingleLevel:
return "one"
case ldap.ScopeWholeSubtree:
return "sub"
default:
return ""
}
}
func parseScope(s string) (int, bool) { func parseScope(s string) (int, bool) {
// NOTE(ericchiang): ScopeBaseObject doesn't really make sense for us because we // NOTE(ericchiang): ScopeBaseObject doesn't really make sense for us because we
// never know the user's or group's DN. // never know the user's or group's DN.
@ -183,26 +134,8 @@ func parseScope(s string) (int, bool) {
return 0, false return 0, false
} }
// Build a list of group attr name to user attr value matchers.
// Function exists here to allow backward compatibility between old and new
// group to user matching implementations.
// See "Config.GroupSearch.UserMatchers" comments for the details
func userMatchers(c *Config, logger log.Logger) []UserMatcher {
if len(c.GroupSearch.UserMatchers) > 0 && c.GroupSearch.UserMatchers[0].UserAttr != "" {
return c.GroupSearch.UserMatchers
}
log.Deprecated(logger, `LDAP: use groupSearch.userMatchers option instead of "userAttr/groupAttr" fields.`)
return []UserMatcher{
{
UserAttr: c.GroupSearch.UserAttr,
GroupAttr: c.GroupSearch.GroupAttr,
},
}
}
// Open returns an authentication strategy using LDAP. // Open returns an authentication strategy using LDAP.
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) { func (c *Config) Open(logger logrus.FieldLogger) (connector.Connector, error) {
conn, err := c.OpenConnector(logger) conn, err := c.OpenConnector(logger)
if err != nil { if err != nil {
return nil, err return nil, err
@ -216,16 +149,16 @@ type refreshData struct {
} }
// OpenConnector is the same as Open but returns a type with all implemented connector interfaces. // OpenConnector is the same as Open but returns a type with all implemented connector interfaces.
func (c *Config) OpenConnector(logger log.Logger) (interface { func (c *Config) OpenConnector(logger logrus.FieldLogger) (interface {
connector.Connector connector.Connector
connector.PasswordConnector connector.PasswordConnector
connector.RefreshConnector connector.RefreshConnector
}, error, }, error) {
) {
return c.openConnector(logger) return c.openConnector(logger)
} }
func (c *Config) openConnector(logger log.Logger) (*ldapConnector, error) { func (c *Config) openConnector(logger logrus.FieldLogger) (*ldapConnector, error) {
requiredFields := []struct { requiredFields := []struct {
name string name string
val string val string
@ -248,9 +181,9 @@ func (c *Config) openConnector(logger log.Logger) (*ldapConnector, error) {
if host, _, err = net.SplitHostPort(c.Host); err != nil { if host, _, err = net.SplitHostPort(c.Host); err != nil {
host = c.Host host = c.Host
if c.InsecureNoSSL { if c.InsecureNoSSL {
c.Host += ":389" c.Host = c.Host + ":389"
} else { } else {
c.Host += ":636" c.Host = c.Host + ":636"
} }
} }
@ -259,7 +192,7 @@ func (c *Config) openConnector(logger log.Logger) (*ldapConnector, error) {
data := c.RootCAData data := c.RootCAData
if len(data) == 0 { if len(data) == 0 {
var err error var err error
if data, err = os.ReadFile(c.RootCA); err != nil { if data, err = ioutil.ReadFile(c.RootCA); err != nil {
return nil, fmt.Errorf("ldap: read ca file: %v", err) return nil, fmt.Errorf("ldap: read ca file: %v", err)
} }
} }
@ -269,25 +202,14 @@ func (c *Config) openConnector(logger log.Logger) (*ldapConnector, error) {
} }
tlsConfig.RootCAs = rootCAs tlsConfig.RootCAs = rootCAs
} }
if c.ClientKey != "" && c.ClientCert != "" {
cert, err := tls.LoadX509KeyPair(c.ClientCert, c.ClientKey)
if err != nil {
return nil, fmt.Errorf("ldap: load client cert failed: %v", err)
}
tlsConfig.Certificates = append(tlsConfig.Certificates, cert)
}
userSearchScope, ok := parseScope(c.UserSearch.Scope) userSearchScope, ok := parseScope(c.UserSearch.Scope)
if !ok { if !ok {
return nil, fmt.Errorf("userSearch.Scope unknown value %q", c.UserSearch.Scope) return nil, fmt.Errorf("userSearch.Scope unknown value %q", c.UserSearch.Scope)
} }
groupSearchScope, ok := parseScope(c.GroupSearch.Scope) groupSearchScope, ok := parseScope(c.GroupSearch.Scope)
if !ok { if !ok {
return nil, fmt.Errorf("groupSearch.Scope unknown value %q", c.GroupSearch.Scope) return nil, fmt.Errorf("userSearch.Scope unknown value %q", c.GroupSearch.Scope)
} }
// TODO(nabokihms): remove it after deleting deprecated groupSearch options
c.GroupSearch.UserMatchers = userMatchers(c, logger)
return &ldapConnector{*c, userSearchScope, groupSearchScope, tlsConfig, logger}, nil return &ldapConnector{*c, userSearchScope, groupSearchScope, tlsConfig, logger}, nil
} }
@ -299,7 +221,7 @@ type ldapConnector struct {
tlsConfig *tls.Config tlsConfig *tls.Config
logger log.Logger logger logrus.FieldLogger
} }
var ( var (
@ -310,24 +232,15 @@ var (
// do initializes a connection to the LDAP directory and passes it to the // do initializes a connection to the LDAP directory and passes it to the
// provided function. It then performs appropriate teardown or reuse before // provided function. It then performs appropriate teardown or reuse before
// returning. // returning.
func (c *ldapConnector) do(_ context.Context, f func(c *ldap.Conn) error) error { func (c *ldapConnector) do(ctx context.Context, f func(c *ldap.Conn) error) error {
// TODO(ericchiang): support context here // TODO(ericchiang): support context here
var ( var (
conn *ldap.Conn conn *ldap.Conn
err error err error
) )
switch { if c.InsecureNoSSL {
case c.InsecureNoSSL:
conn, err = ldap.Dial("tcp", c.Host) conn, err = ldap.Dial("tcp", c.Host)
case c.StartTLS: } else {
conn, err = ldap.Dial("tcp", c.Host)
if err != nil {
return fmt.Errorf("failed to connect: %v", err)
}
if err := conn.StartTLS(c.tlsConfig); err != nil {
return fmt.Errorf("start TLS failed: %v", err)
}
default:
conn, err = ldap.DialTLS("tcp", c.Host, c.tlsConfig) conn, err = ldap.DialTLS("tcp", c.Host, c.tlsConfig)
} }
if err != nil { if err != nil {
@ -336,11 +249,7 @@ func (c *ldapConnector) do(_ context.Context, f func(c *ldap.Conn) error) error
defer conn.Close() defer conn.Close()
// If bindDN and bindPW are empty this will default to an anonymous bind. // If bindDN and bindPW are empty this will default to an anonymous bind.
if c.BindDN == "" && c.BindPW == "" { if err := conn.Bind(c.BindDN, c.BindPW); err != nil {
if err := conn.UnauthenticatedBind(""); err != nil {
return fmt.Errorf("ldap: initial anonymous bind failed: %v", err)
}
} else if err := conn.Bind(c.BindDN, c.BindPW); err != nil {
return fmt.Errorf("ldap: initial bind for user %q failed: %v", c.BindDN, err) return fmt.Errorf("ldap: initial bind for user %q failed: %v", c.BindDN, err)
} }
@ -376,6 +285,11 @@ func (c *ldapConnector) identityFromEntry(user ldap.Entry) (ident connector.Iden
if ident.UserID = getAttr(user, c.UserSearch.IDAttr); ident.UserID == "" { if ident.UserID = getAttr(user, c.UserSearch.IDAttr); ident.UserID == "" {
missing = append(missing, c.UserSearch.IDAttr) missing = append(missing, c.UserSearch.IDAttr)
} }
if ident.Email = getAttr(user, c.UserSearch.EmailAttr); ident.Email == "" {
missing = append(missing, c.UserSearch.EmailAttr)
}
// TODO(ericchiang): Let this value be set from an attribute.
ident.EmailVerified = true
if c.UserSearch.NameAttr != "" { if c.UserSearch.NameAttr != "" {
if ident.Username = getAttr(user, c.UserSearch.NameAttr); ident.Username == "" { if ident.Username = getAttr(user, c.UserSearch.NameAttr); ident.Username == "" {
@ -383,20 +297,6 @@ func (c *ldapConnector) identityFromEntry(user ldap.Entry) (ident connector.Iden
} }
} }
if c.UserSearch.PreferredUsernameAttrAttr != "" {
if ident.PreferredUsername = getAttr(user, c.UserSearch.PreferredUsernameAttrAttr); ident.PreferredUsername == "" {
missing = append(missing, c.UserSearch.PreferredUsernameAttrAttr)
}
}
if c.UserSearch.EmailSuffix != "" {
ident.Email = ident.Username + "@" + c.UserSearch.EmailSuffix
} else if ident.Email = getAttr(user, c.UserSearch.EmailAttr); ident.Email == "" {
missing = append(missing, c.UserSearch.EmailAttr)
}
// TODO(ericchiang): Let this value be set from an attribute.
ident.EmailVerified = true
if len(missing) != 0 { if len(missing) != 0 {
err := fmt.Errorf("ldap: entry %q missing following required attribute(s): %q", user.DN, missing) err := fmt.Errorf("ldap: entry %q missing following required attribute(s): %q", user.DN, missing)
return connector.Identity{}, err return connector.Identity{}, err
@ -405,6 +305,7 @@ func (c *ldapConnector) identityFromEntry(user ldap.Entry) (ident connector.Iden
} }
func (c *ldapConnector) userEntry(conn *ldap.Conn, username string) (user ldap.Entry, found bool, err error) { func (c *ldapConnector) userEntry(conn *ldap.Conn, username string) (user ldap.Entry, found bool, err error) {
filter := fmt.Sprintf("(%s=%s)", c.UserSearch.Username, ldap.EscapeFilter(username)) filter := fmt.Sprintf("(%s=%s)", c.UserSearch.Username, ldap.EscapeFilter(username))
if c.UserSearch.Filter != "" { if c.UserSearch.Filter != "" {
filter = fmt.Sprintf("(&%s%s)", c.UserSearch.Filter, filter) filter = fmt.Sprintf("(&%s%s)", c.UserSearch.Filter, filter)
@ -419,24 +320,14 @@ func (c *ldapConnector) userEntry(conn *ldap.Conn, username string) (user ldap.E
Attributes: []string{ Attributes: []string{
c.UserSearch.IDAttr, c.UserSearch.IDAttr,
c.UserSearch.EmailAttr, c.UserSearch.EmailAttr,
c.GroupSearch.UserAttr,
// TODO(ericchiang): what if this contains duplicate values? // TODO(ericchiang): what if this contains duplicate values?
}, },
} }
for _, matcher := range c.GroupSearch.UserMatchers {
req.Attributes = append(req.Attributes, matcher.UserAttr)
}
if c.UserSearch.NameAttr != "" { if c.UserSearch.NameAttr != "" {
req.Attributes = append(req.Attributes, c.UserSearch.NameAttr) req.Attributes = append(req.Attributes, c.UserSearch.NameAttr)
} }
if c.UserSearch.PreferredUsernameAttrAttr != "" {
req.Attributes = append(req.Attributes, c.UserSearch.PreferredUsernameAttrAttr)
}
c.logger.Infof("performing ldap search %s %s %s",
req.BaseDN, scopeString(req.Scope), req.Filter)
resp, err := conn.Search(req) resp, err := conn.Search(req)
if err != nil { if err != nil {
return ldap.Entry{}, false, fmt.Errorf("ldap: search with filter %q failed: %v", req.Filter, err) return ldap.Entry{}, false, fmt.Errorf("ldap: search with filter %q failed: %v", req.Filter, err)
@ -447,16 +338,14 @@ func (c *ldapConnector) userEntry(conn *ldap.Conn, username string) (user ldap.E
c.logger.Errorf("ldap: no results returned for filter: %q", filter) c.logger.Errorf("ldap: no results returned for filter: %q", filter)
return ldap.Entry{}, false, nil return ldap.Entry{}, false, nil
case 1: case 1:
user = *resp.Entries[0] return *resp.Entries[0], true, nil
c.logger.Infof("username %q mapped to entry %s", username, user.DN)
return user, true, nil
default: default:
return ldap.Entry{}, false, fmt.Errorf("ldap: filter returned multiple (%d) results: %q", n, filter) return ldap.Entry{}, false, fmt.Errorf("ldap: filter returned multiple (%d) results: %q", n, filter)
} }
} }
func (c *ldapConnector) Login(ctx context.Context, s connector.Scopes, username, password string) (ident connector.Identity, validPass bool, err error) { func (c *ldapConnector) Login(ctx context.Context, s connector.Scopes, username, password string) (ident connector.Identity, validPass bool, err error) {
// make this check to avoid unauthenticated bind to the LDAP server. // make this check to avoid anonymous bind to the LDAP server.
if password == "" { if password == "" {
return connector.Identity{}, false, nil return connector.Identity{}, false, nil
} }
@ -483,17 +372,12 @@ func (c *ldapConnector) Login(ctx context.Context, s connector.Scopes, username,
if err := conn.Bind(user.DN, password); err != nil { if err := conn.Bind(user.DN, password); err != nil {
// Detect a bad password through the LDAP error code. // Detect a bad password through the LDAP error code.
if ldapErr, ok := err.(*ldap.Error); ok { if ldapErr, ok := err.(*ldap.Error); ok {
switch ldapErr.ResultCode { if ldapErr.ResultCode == ldap.LDAPResultInvalidCredentials {
case ldap.LDAPResultInvalidCredentials:
c.logger.Errorf("ldap: invalid password for user %q", user.DN) c.logger.Errorf("ldap: invalid password for user %q", user.DN)
incorrectPass = true incorrectPass = true
return nil return nil
case ldap.LDAPResultConstraintViolation:
c.logger.Errorf("ldap: constraint violation for user %q: %s", user.DN, ldapErr.Error())
incorrectPass = true
return nil
} }
} // will also catch all ldap.Error without a case statement above }
return fmt.Errorf("ldap: failed to bind as dn %q: %v", user.DN, err) return fmt.Errorf("ldap: failed to bind as dn %q: %v", user.DN, err)
} }
return nil return nil
@ -535,7 +419,7 @@ func (c *ldapConnector) Login(ctx context.Context, s connector.Scopes, username,
func (c *ldapConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) { func (c *ldapConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) {
var data refreshData var data refreshData
if err := json.Unmarshal(ident.ConnectorData, &data); err != nil { if err := json.Unmarshal(ident.ConnectorData, &data); err != nil {
return ident, fmt.Errorf("ldap: failed to unmarshal internal data: %v", err) return ident, fmt.Errorf("ldap: failed to unamrshal internal data: %v", err)
} }
var user ldap.Entry var user ldap.Entry
@ -580,9 +464,8 @@ func (c *ldapConnector) groups(ctx context.Context, user ldap.Entry) ([]string,
} }
var groups []*ldap.Entry var groups []*ldap.Entry
for _, matcher := range c.GroupSearch.UserMatchers { for _, attr := range getAttrs(user, c.GroupSearch.UserAttr) {
for _, attr := range getAttrs(user, matcher.UserAttr) { filter := fmt.Sprintf("(%s=%s)", c.GroupSearch.GroupAttr, ldap.EscapeFilter(attr))
filter := fmt.Sprintf("(%s=%s)", matcher.GroupAttr, ldap.EscapeFilter(attr))
if c.GroupSearch.Filter != "" { if c.GroupSearch.Filter != "" {
filter = fmt.Sprintf("(&%s%s)", c.GroupSearch.Filter, filter) filter = fmt.Sprintf("(&%s%s)", c.GroupSearch.Filter, filter)
} }
@ -596,8 +479,6 @@ func (c *ldapConnector) groups(ctx context.Context, user ldap.Entry) ([]string,
gotGroups := false gotGroups := false
if err := c.do(ctx, func(conn *ldap.Conn) error { if err := c.do(ctx, func(conn *ldap.Conn) error {
c.logger.Infof("performing ldap search %s %s %s",
req.BaseDN, scopeString(req.Scope), req.Filter)
resp, err := conn.Search(req) resp, err := conn.Search(req)
if err != nil { if err != nil {
return fmt.Errorf("ldap: search failed: %v", err) return fmt.Errorf("ldap: search failed: %v", err)
@ -613,9 +494,8 @@ func (c *ldapConnector) groups(ctx context.Context, user ldap.Entry) ([]string,
c.logger.Errorf("ldap: groups search with filter %q returned no groups", filter) c.logger.Errorf("ldap: groups search with filter %q returned no groups", filter)
} }
} }
}
groupNames := make([]string, 0, len(groups)) var groupNames []string
for _, group := range groups { for _, group := range groups {
name := getAttr(*group, c.GroupSearch.NameAttr) name := getAttr(*group, c.GroupSearch.NameAttr)
if name == "" { if name == "" {
@ -631,7 +511,3 @@ func (c *ldapConnector) groups(ctx context.Context, user ldap.Entry) ([]string,
} }
return groupNames, nil return groupNames, nil
} }
func (c *ldapConnector) Prompt() string {
return c.UsernamePrompt
}

View file

@ -1,27 +1,25 @@
package ldap package ldap
import ( import (
"bytes"
"context" "context"
"fmt" "io/ioutil"
"io" "net/url"
"os" "os"
"os/exec"
"path/filepath"
"sync"
"testing" "testing"
"text/template"
"time"
"github.com/Sirupsen/logrus"
"github.com/kylelemons/godebug/pretty" "github.com/kylelemons/godebug/pretty"
"github.com/sirupsen/logrus"
"github.com/dexidp/dex/connector" "github.com/coreos/dex/connector"
) )
// connectionMethod indicates how the test should connect to the LDAP server. const envVar = "DEX_LDAP_TESTS"
type connectionMethod int32
const (
connectStartTLS connectionMethod = iota
connectLDAPS
connectLDAP
connectInsecureSkipVerify
)
// subtest is a login test against a given schema. // subtest is a login test against a given schema.
type subtest struct { type subtest struct {
@ -41,8 +39,35 @@ type subtest struct {
} }
func TestQuery(t *testing.T) { func TestQuery(t *testing.T) {
schema := `
dn: dc=example,dc=org
objectClass: dcObject
objectClass: organization
o: Example Company
dc: example
dn: ou=People,dc=example,dc=org
objectClass: organizationalUnit
ou: People
dn: cn=jane,ou=People,dc=example,dc=org
objectClass: person
objectClass: inetOrgPerson
sn: doe
cn: jane
mail: janedoe@example.com
userpassword: foo
dn: cn=john,ou=People,dc=example,dc=org
objectClass: person
objectClass: inetOrgPerson
sn: doe
cn: john
mail: johndoe@example.com
userpassword: bar
`
c := &Config{} c := &Config{}
c.UserSearch.BaseDN = "ou=People,ou=TestQuery,dc=example,dc=org" c.UserSearch.BaseDN = "ou=People,dc=example,dc=org"
c.UserSearch.NameAttr = "cn" c.UserSearch.NameAttr = "cn"
c.UserSearch.EmailAttr = "mail" c.UserSearch.EmailAttr = "mail"
c.UserSearch.IDAttr = "DN" c.UserSearch.IDAttr = "DN"
@ -54,7 +79,7 @@ func TestQuery(t *testing.T) {
username: "jane", username: "jane",
password: "foo", password: "foo",
want: connector.Identity{ want: connector.Identity{
UserID: "cn=jane,ou=People,ou=TestQuery,dc=example,dc=org", UserID: "cn=jane,ou=People,dc=example,dc=org",
Username: "jane", Username: "jane",
Email: "janedoe@example.com", Email: "janedoe@example.com",
EmailVerified: true, EmailVerified: true,
@ -65,7 +90,7 @@ func TestQuery(t *testing.T) {
username: "john", username: "john",
password: "bar", password: "bar",
want: connector.Identity{ want: connector.Identity{
UserID: "cn=john,ou=People,ou=TestQuery,dc=example,dc=org", UserID: "cn=john,ou=People,dc=example,dc=org",
Username: "john", Username: "john",
Email: "johndoe@example.com", Email: "johndoe@example.com",
EmailVerified: true, EmailVerified: true,
@ -85,108 +110,63 @@ func TestQuery(t *testing.T) {
}, },
} }
runTests(t, connectLDAP, c, tests) runTests(t, schema, c, tests)
}
func TestQueryWithEmailSuffix(t *testing.T) {
c := &Config{}
c.UserSearch.BaseDN = "ou=People,ou=TestQueryWithEmailSuffix,dc=example,dc=org"
c.UserSearch.NameAttr = "cn"
c.UserSearch.EmailSuffix = "test.example.com"
c.UserSearch.IDAttr = "DN"
c.UserSearch.Username = "cn"
tests := []subtest{
{
name: "ignoremailattr",
username: "jane",
password: "foo",
want: connector.Identity{
UserID: "cn=jane,ou=People,ou=TestQueryWithEmailSuffix,dc=example,dc=org",
Username: "jane",
Email: "jane@test.example.com",
EmailVerified: true,
},
},
{
name: "nomailattr",
username: "john",
password: "bar",
want: connector.Identity{
UserID: "cn=john,ou=People,ou=TestQueryWithEmailSuffix,dc=example,dc=org",
Username: "john",
Email: "john@test.example.com",
EmailVerified: true,
},
},
}
runTests(t, connectLDAP, c, tests)
}
func TestUserFilter(t *testing.T) {
c := &Config{}
c.UserSearch.BaseDN = "ou=TestUserFilter,dc=example,dc=org"
c.UserSearch.NameAttr = "cn"
c.UserSearch.EmailAttr = "mail"
c.UserSearch.IDAttr = "DN"
c.UserSearch.Username = "cn"
c.UserSearch.Filter = "(ou:dn:=Seattle)"
tests := []subtest{
{
name: "validpassword",
username: "jane",
password: "foo",
want: connector.Identity{
UserID: "cn=jane,ou=People,ou=Seattle,ou=TestUserFilter,dc=example,dc=org",
Username: "jane",
Email: "janedoe@example.com",
EmailVerified: true,
},
},
{
name: "validpassword2",
username: "john",
password: "bar",
want: connector.Identity{
UserID: "cn=john,ou=People,ou=Seattle,ou=TestUserFilter,dc=example,dc=org",
Username: "john",
Email: "johndoe@example.com",
EmailVerified: true,
},
},
{
name: "invalidpassword",
username: "jane",
password: "badpassword",
wantBadPW: true,
},
{
name: "invaliduser",
username: "idontexist",
password: "foo",
wantBadPW: true, // Want invalid password, not a query error.
},
}
runTests(t, connectLDAP, c, tests)
} }
func TestGroupQuery(t *testing.T) { func TestGroupQuery(t *testing.T) {
schema := `
dn: dc=example,dc=org
objectClass: dcObject
objectClass: organization
o: Example Company
dc: example
dn: ou=People,dc=example,dc=org
objectClass: organizationalUnit
ou: People
dn: cn=jane,ou=People,dc=example,dc=org
objectClass: person
objectClass: inetOrgPerson
sn: doe
cn: jane
mail: janedoe@example.com
userpassword: foo
dn: cn=john,ou=People,dc=example,dc=org
objectClass: person
objectClass: inetOrgPerson
sn: doe
cn: john
mail: johndoe@example.com
userpassword: bar
# Group definitions.
dn: ou=Groups,dc=example,dc=org
objectClass: organizationalUnit
ou: Groups
dn: cn=admins,ou=Groups,dc=example,dc=org
objectClass: groupOfNames
cn: admins
member: cn=john,ou=People,dc=example,dc=org
member: cn=jane,ou=People,dc=example,dc=org
dn: cn=developers,ou=Groups,dc=example,dc=org
objectClass: groupOfNames
cn: developers
member: cn=jane,ou=People,dc=example,dc=org
`
c := &Config{} c := &Config{}
c.UserSearch.BaseDN = "ou=People,ou=TestGroupQuery,dc=example,dc=org" c.UserSearch.BaseDN = "ou=People,dc=example,dc=org"
c.UserSearch.NameAttr = "cn" c.UserSearch.NameAttr = "cn"
c.UserSearch.EmailAttr = "mail" c.UserSearch.EmailAttr = "mail"
c.UserSearch.IDAttr = "DN" c.UserSearch.IDAttr = "DN"
c.UserSearch.Username = "cn" c.UserSearch.Username = "cn"
c.GroupSearch.BaseDN = "ou=Groups,ou=TestGroupQuery,dc=example,dc=org" c.GroupSearch.BaseDN = "ou=Groups,dc=example,dc=org"
c.GroupSearch.UserMatchers = []UserMatcher{ c.GroupSearch.UserAttr = "DN"
{ c.GroupSearch.GroupAttr = "member"
UserAttr: "DN",
GroupAttr: "member",
},
}
c.GroupSearch.NameAttr = "cn" c.GroupSearch.NameAttr = "cn"
tests := []subtest{ tests := []subtest{
@ -196,7 +176,7 @@ func TestGroupQuery(t *testing.T) {
password: "foo", password: "foo",
groups: true, groups: true,
want: connector.Identity{ want: connector.Identity{
UserID: "cn=jane,ou=People,ou=TestGroupQuery,dc=example,dc=org", UserID: "cn=jane,ou=People,dc=example,dc=org",
Username: "jane", Username: "jane",
Email: "janedoe@example.com", Email: "janedoe@example.com",
EmailVerified: true, EmailVerified: true,
@ -209,7 +189,7 @@ func TestGroupQuery(t *testing.T) {
password: "bar", password: "bar",
groups: true, groups: true,
want: connector.Identity{ want: connector.Identity{
UserID: "cn=john,ou=People,ou=TestGroupQuery,dc=example,dc=org", UserID: "cn=john,ou=People,dc=example,dc=org",
Username: "john", Username: "john",
Email: "johndoe@example.com", Email: "johndoe@example.com",
EmailVerified: true, EmailVerified: true,
@ -218,23 +198,74 @@ func TestGroupQuery(t *testing.T) {
}, },
} }
runTests(t, connectLDAP, c, tests) runTests(t, schema, c, tests)
} }
func TestGroupsOnUserEntity(t *testing.T) { func TestGroupsOnUserEntity(t *testing.T) {
schema := `
dn: dc=example,dc=org
objectClass: dcObject
objectClass: organization
o: Example Company
dc: example
dn: ou=People,dc=example,dc=org
objectClass: organizationalUnit
ou: People
# Groups are enumerated as part of the user entity instead of the members being
# a list on the group entity.
dn: cn=jane,ou=People,dc=example,dc=org
objectClass: person
objectClass: inetOrgPerson
sn: doe
cn: jane
mail: janedoe@example.com
userpassword: foo
departmentNumber: 1000
departmentNumber: 1001
dn: cn=john,ou=People,dc=example,dc=org
objectClass: person
objectClass: inetOrgPerson
sn: doe
cn: john
mail: johndoe@example.com
userpassword: bar
departmentNumber: 1000
departmentNumber: 1002
# Group definitions. Notice that they don't have any "member" field.
dn: ou=Groups,dc=example,dc=org
objectClass: organizationalUnit
ou: Groups
dn: cn=admins,ou=Groups,dc=example,dc=org
objectClass: posixGroup
cn: admins
gidNumber: 1000
dn: cn=developers,ou=Groups,dc=example,dc=org
objectClass: posixGroup
cn: developers
gidNumber: 1001
dn: cn=designers,ou=Groups,dc=example,dc=org
objectClass: posixGroup
cn: designers
gidNumber: 1002
`
c := &Config{} c := &Config{}
c.UserSearch.BaseDN = "ou=People,ou=TestGroupsOnUserEntity,dc=example,dc=org" c.UserSearch.BaseDN = "ou=People,dc=example,dc=org"
c.UserSearch.NameAttr = "cn" c.UserSearch.NameAttr = "cn"
c.UserSearch.EmailAttr = "mail" c.UserSearch.EmailAttr = "mail"
c.UserSearch.IDAttr = "DN" c.UserSearch.IDAttr = "DN"
c.UserSearch.Username = "cn" c.UserSearch.Username = "cn"
c.GroupSearch.BaseDN = "ou=Groups,ou=TestGroupsOnUserEntity,dc=example,dc=org" c.GroupSearch.BaseDN = "ou=Groups,dc=example,dc=org"
c.GroupSearch.UserMatchers = []UserMatcher{ c.GroupSearch.UserAttr = "departmentNumber"
{ c.GroupSearch.GroupAttr = "gidNumber"
UserAttr: "departmentNumber",
GroupAttr: "gidNumber",
},
}
c.GroupSearch.NameAttr = "cn" c.GroupSearch.NameAttr = "cn"
tests := []subtest{ tests := []subtest{
{ {
@ -243,7 +274,7 @@ func TestGroupsOnUserEntity(t *testing.T) {
password: "foo", password: "foo",
groups: true, groups: true,
want: connector.Identity{ want: connector.Identity{
UserID: "cn=jane,ou=People,ou=TestGroupsOnUserEntity,dc=example,dc=org", UserID: "cn=jane,ou=People,dc=example,dc=org",
Username: "jane", Username: "jane",
Email: "janedoe@example.com", Email: "janedoe@example.com",
EmailVerified: true, EmailVerified: true,
@ -256,7 +287,7 @@ func TestGroupsOnUserEntity(t *testing.T) {
password: "bar", password: "bar",
groups: true, groups: true,
want: connector.Identity{ want: connector.Identity{
UserID: "cn=john,ou=People,ou=TestGroupsOnUserEntity,dc=example,dc=org", UserID: "cn=john,ou=People,dc=example,dc=org",
Username: "john", Username: "john",
Email: "johndoe@example.com", Email: "johndoe@example.com",
EmailVerified: true, EmailVerified: true,
@ -264,271 +295,109 @@ func TestGroupsOnUserEntity(t *testing.T) {
}, },
}, },
} }
runTests(t, connectLDAP, c, tests) runTests(t, schema, c, tests)
} }
func TestGroupFilter(t *testing.T) { // runTests runs a set of tests against an LDAP schema. It does this by
c := &Config{} // setting up an OpenLDAP server and injecting the provided scheme.
c.UserSearch.BaseDN = "ou=People,ou=TestGroupFilter,dc=example,dc=org"
c.UserSearch.NameAttr = "cn"
c.UserSearch.EmailAttr = "mail"
c.UserSearch.IDAttr = "DN"
c.UserSearch.Username = "cn"
c.GroupSearch.BaseDN = "ou=TestGroupFilter,dc=example,dc=org"
c.GroupSearch.UserMatchers = []UserMatcher{
{
UserAttr: "DN",
GroupAttr: "member",
},
}
c.GroupSearch.NameAttr = "cn"
c.GroupSearch.Filter = "(ou:dn:=Seattle)" // ignore other groups
tests := []subtest{
{
name: "validpassword",
username: "jane",
password: "foo",
groups: true,
want: connector.Identity{
UserID: "cn=jane,ou=People,ou=TestGroupFilter,dc=example,dc=org",
Username: "jane",
Email: "janedoe@example.com",
EmailVerified: true,
Groups: []string{"admins", "developers"},
},
},
{
name: "validpassword2",
username: "john",
password: "bar",
groups: true,
want: connector.Identity{
UserID: "cn=john,ou=People,ou=TestGroupFilter,dc=example,dc=org",
Username: "john",
Email: "johndoe@example.com",
EmailVerified: true,
Groups: []string{"admins"},
},
},
}
runTests(t, connectLDAP, c, tests)
}
func TestGroupToUserMatchers(t *testing.T) {
c := &Config{}
c.UserSearch.BaseDN = "ou=People,ou=TestGroupToUserMatchers,dc=example,dc=org"
c.UserSearch.NameAttr = "cn"
c.UserSearch.EmailAttr = "mail"
c.UserSearch.IDAttr = "DN"
c.UserSearch.Username = "cn"
c.GroupSearch.BaseDN = "ou=TestGroupToUserMatchers,dc=example,dc=org"
c.GroupSearch.UserMatchers = []UserMatcher{
{
UserAttr: "DN",
GroupAttr: "member",
},
{
UserAttr: "uid",
GroupAttr: "memberUid",
},
}
c.GroupSearch.NameAttr = "cn"
c.GroupSearch.Filter = "(|(objectClass=posixGroup)(objectClass=groupOfNames))" // search all group types
tests := []subtest{
{
name: "validpassword",
username: "jane",
password: "foo",
groups: true,
want: connector.Identity{
UserID: "cn=jane,ou=People,ou=TestGroupToUserMatchers,dc=example,dc=org",
Username: "jane",
Email: "janedoe@example.com",
EmailVerified: true,
Groups: []string{"admins", "developers", "frontend"},
},
},
{
name: "validpassword2",
username: "john",
password: "bar",
groups: true,
want: connector.Identity{
UserID: "cn=john,ou=People,ou=TestGroupToUserMatchers,dc=example,dc=org",
Username: "john",
Email: "johndoe@example.com",
EmailVerified: true,
Groups: []string{"admins", "qa", "logger"},
},
},
}
runTests(t, connectLDAP, c, tests)
}
// Test deprecated group to user matching implementation
// which was left for backward compatibility.
// See "Config.GroupSearch.UserMatchers" comments for the details
func TestDeprecatedGroupToUserMatcher(t *testing.T) {
c := &Config{}
c.UserSearch.BaseDN = "ou=People,ou=TestDeprecatedGroupToUserMatcher,dc=example,dc=org"
c.UserSearch.NameAttr = "cn"
c.UserSearch.EmailAttr = "mail"
c.UserSearch.IDAttr = "DN"
c.UserSearch.Username = "cn"
c.GroupSearch.BaseDN = "ou=TestDeprecatedGroupToUserMatcher,dc=example,dc=org"
c.GroupSearch.UserAttr = "DN"
c.GroupSearch.GroupAttr = "member"
c.GroupSearch.NameAttr = "cn"
c.GroupSearch.Filter = "(ou:dn:=Seattle)" // ignore other groups
tests := []subtest{
{
name: "validpassword",
username: "jane",
password: "foo",
groups: true,
want: connector.Identity{
UserID: "cn=jane,ou=People,ou=TestDeprecatedGroupToUserMatcher,dc=example,dc=org",
Username: "jane",
Email: "janedoe@example.com",
EmailVerified: true,
Groups: []string{"admins", "developers"},
},
},
{
name: "validpassword2",
username: "john",
password: "bar",
groups: true,
want: connector.Identity{
UserID: "cn=john,ou=People,ou=TestDeprecatedGroupToUserMatcher,dc=example,dc=org",
Username: "john",
Email: "johndoe@example.com",
EmailVerified: true,
Groups: []string{"admins"},
},
},
}
runTests(t, connectLDAP, c, tests)
}
func TestStartTLS(t *testing.T) {
c := &Config{}
c.UserSearch.BaseDN = "ou=People,ou=TestStartTLS,dc=example,dc=org"
c.UserSearch.NameAttr = "cn"
c.UserSearch.EmailAttr = "mail"
c.UserSearch.IDAttr = "DN"
c.UserSearch.Username = "cn"
tests := []subtest{
{
name: "validpassword",
username: "jane",
password: "foo",
want: connector.Identity{
UserID: "cn=jane,ou=People,ou=TestStartTLS,dc=example,dc=org",
Username: "jane",
Email: "janedoe@example.com",
EmailVerified: true,
},
},
}
runTests(t, connectStartTLS, c, tests)
}
func TestInsecureSkipVerify(t *testing.T) {
c := &Config{}
c.UserSearch.BaseDN = "ou=People,ou=TestInsecureSkipVerify,dc=example,dc=org"
c.UserSearch.NameAttr = "cn"
c.UserSearch.EmailAttr = "mail"
c.UserSearch.IDAttr = "DN"
c.UserSearch.Username = "cn"
tests := []subtest{
{
name: "validpassword",
username: "jane",
password: "foo",
want: connector.Identity{
UserID: "cn=jane,ou=People,ou=TestInsecureSkipVerify,dc=example,dc=org",
Username: "jane",
Email: "janedoe@example.com",
EmailVerified: true,
},
},
}
runTests(t, connectInsecureSkipVerify, c, tests)
}
func TestLDAPS(t *testing.T) {
c := &Config{}
c.UserSearch.BaseDN = "ou=People,ou=TestLDAPS,dc=example,dc=org"
c.UserSearch.NameAttr = "cn"
c.UserSearch.EmailAttr = "mail"
c.UserSearch.IDAttr = "DN"
c.UserSearch.Username = "cn"
tests := []subtest{
{
name: "validpassword",
username: "jane",
password: "foo",
want: connector.Identity{
UserID: "cn=jane,ou=People,ou=TestLDAPS,dc=example,dc=org",
Username: "jane",
Email: "janedoe@example.com",
EmailVerified: true,
},
},
}
runTests(t, connectLDAPS, c, tests)
}
func TestUsernamePrompt(t *testing.T) {
tests := map[string]struct {
config Config
expected string
}{
"with usernamePrompt unset it returns \"\"": {
config: Config{},
expected: "",
},
"with usernamePrompt set it returns that": {
config: Config{UsernamePrompt: "Email address"},
expected: "Email address",
},
}
for n, d := range tests {
t.Run(n, func(t *testing.T) {
conn := &ldapConnector{Config: d.config}
if actual := conn.Prompt(); actual != d.expected {
t.Errorf("expected %v, got %v", d.expected, actual)
}
})
}
}
func getenv(key, defaultVal string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultVal
}
// runTests runs a set of tests against an LDAP schema.
// //
// The tests require LDAP to be runnning. // The tests require the slapd and ldapadd binaries available in the host
// You can use the provided docker-compose file to setup an LDAP server. // machine's PATH.
func runTests(t *testing.T, connMethod connectionMethod, config *Config, tests []subtest) { //
ldapHost := os.Getenv("DEX_LDAP_HOST") // The DEX_LDAP_TESTS must be set to "1"
if ldapHost == "" { func runTests(t *testing.T, schema string, config *Config, tests []subtest) {
t.Skipf(`test environment variable "DEX_LDAP_HOST" not set, skipping`) if os.Getenv(envVar) != "1" {
t.Skipf("%s not set. Skipping test (run 'export %s=1' to run tests)", envVar, envVar)
}
for _, cmd := range []string{"slapd", "ldapadd"} {
if _, err := exec.LookPath(cmd); err != nil {
t.Errorf("%s not available", cmd)
}
}
tempDir, err := ioutil.TempDir("", "")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
configBytes := new(bytes.Buffer)
if err := slapdConfigTmpl.Execute(configBytes, tmplData{tempDir, includes(t)}); err != nil {
t.Fatal(err)
}
configPath := filepath.Join(tempDir, "ldap.conf")
if err := ioutil.WriteFile(configPath, configBytes.Bytes(), 0644); err != nil {
t.Fatal(err)
}
schemaPath := filepath.Join(tempDir, "schema.ldap")
if err := ioutil.WriteFile(schemaPath, []byte(schema), 0644); err != nil {
t.Fatal(err)
}
socketPath := url.QueryEscape(filepath.Join(tempDir, "ldap.unix"))
slapdOut := new(bytes.Buffer)
cmd := exec.Command(
"slapd",
"-d", "any",
"-h", "ldap://localhost:10363/ ldaps://localhost:10636/ ldapi://"+socketPath,
"-f", configPath,
)
cmd.Stdout = slapdOut
cmd.Stderr = slapdOut
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
var (
// Wait group finishes once slapd has exited.
//
// Use a wait group because multiple goroutines can't listen on
// cmd.Wait(). It triggers the race detector.
wg = new(sync.WaitGroup)
// Ensure only one condition can set the slapdFailed boolean.
once = new(sync.Once)
slapdFailed bool
)
wg.Add(1)
go func() { cmd.Wait(); wg.Done() }()
defer func() {
if slapdFailed {
// If slapd exited before it was killed, print its logs.
t.Logf("%s\n", slapdOut)
}
}()
go func() {
wg.Wait()
once.Do(func() { slapdFailed = true })
}()
defer func() {
once.Do(func() { slapdFailed = false })
cmd.Process.Kill()
wg.Wait()
}()
// Wait for slapd to come up.
time.Sleep(100 * time.Millisecond)
ldapadd := exec.Command(
"ldapadd", "-x",
"-D", "cn=admin,dc=example,dc=org",
"-w", "admin",
"-f", schemaPath,
"-H", "ldap://localhost:10363/",
)
if out, err := ldapadd.CombinedOutput(); err != nil {
t.Errorf("ldapadd: %s", out)
return
} }
// Shallow copy. // Shallow copy.
@ -536,26 +405,12 @@ func runTests(t *testing.T, connMethod connectionMethod, config *Config, tests [
// We need to configure host parameters but don't want to overwrite user or // We need to configure host parameters but don't want to overwrite user or
// group search configuration. // group search configuration.
switch connMethod { c.Host = "localhost:10363"
case connectStartTLS:
c.Host = fmt.Sprintf("%s:%s", ldapHost, getenv("DEX_LDAP_PORT", "389"))
c.RootCA = "testdata/certs/ca.crt"
c.StartTLS = true
case connectLDAPS:
c.Host = fmt.Sprintf("%s:%s", ldapHost, getenv("DEX_LDAP_TLS_PORT", "636"))
c.RootCA = "testdata/certs/ca.crt"
case connectInsecureSkipVerify:
c.Host = fmt.Sprintf("%s:%s", ldapHost, getenv("DEX_LDAP_TLS_PORT", "636"))
c.InsecureSkipVerify = true
case connectLDAP:
c.Host = fmt.Sprintf("%s:%s", ldapHost, getenv("DEX_LDAP_PORT", "389"))
c.InsecureNoSSL = true c.InsecureNoSSL = true
}
c.BindDN = "cn=admin,dc=example,dc=org" c.BindDN = "cn=admin,dc=example,dc=org"
c.BindPW = "admin" c.BindPW = "admin"
l := &logrus.Logger{Out: io.Discard, Formatter: &logrus.TextFormatter{}} l := &logrus.Logger{Out: ioutil.Discard, Formatter: &logrus.TextFormatter{}}
conn, err := c.openConnector(l) conn, err := c.openConnector(l)
if err != nil { if err != nil {
@ -614,3 +469,82 @@ func runTests(t *testing.T, connMethod connectionMethod, config *Config, tests [
}) })
} }
} }
// Standard OpenLDAP schema files to include.
//
// These are copied from the /etc/openldap/schema directory.
var includeFiles = []string{
"core.schema",
"cosine.schema",
"inetorgperson.schema",
"misc.schema",
"nis.schema",
"openldap.schema",
}
// tmplData is the struct used to execute the SLAPD config template.
type tmplData struct {
// Directory for database to be writen to.
TempDir string
// List of schema files to include.
Includes []string
}
// Config template copied from:
// http://www.zytrax.com/books/ldap/ch5/index.html#step1-slapd
var slapdConfigTmpl = template.Must(template.New("").Parse(`
{{ range $i, $include := .Includes }}
include {{ $include }}
{{ end }}
# MODULELOAD definitions
# not required (comment out) before version 2.3
moduleload back_bdb.la
database bdb
suffix "dc=example,dc=org"
# root or superuser
rootdn "cn=admin,dc=example,dc=org"
rootpw admin
# The database directory MUST exist prior to running slapd AND
# change path as necessary
directory {{ .TempDir }}
# Indices to maintain for this directory
# unique id so equality match only
index uid eq
# allows general searching on commonname, givenname and email
index cn,gn,mail eq,sub
# allows multiple variants on surname searching
index sn eq,sub
# sub above includes subintial,subany,subfinal
# optimise department searches
index ou eq
# if searches will include objectClass uncomment following
# index objectClass eq
# shows use of default index parameter
index default eq,sub
# indices missing - uses default eq,sub
index telephonenumber
# other database parameters
# read more in slapd.conf reference section
cachesize 10000
checkpoint 128 15
`))
func includes(t *testing.T) (paths []string) {
wd, err := os.Getwd()
if err != nil {
t.Fatalf("getting working directory: %v", err)
}
for _, f := range includeFiles {
p := filepath.Join(wd, "testdata", f)
if _, err := os.Stat(p); err != nil {
t.Fatalf("failed to find schema file: %s %v", p, err)
}
paths = append(paths, p)
}
return
}

View file

@ -1,19 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIC/TCCAeWgAwIBAgIJAIrt+AlVUsXKMA0GCSqGSIb3DQEBCwUAMBUxEzARBgNV
BAMMCmxkYXAtdGVzdHMwHhcNMTcwNDEyMjAxNzI5WhcNNDQwODI4MjAxNzI5WjAV
MRMwEQYDVQQDDApsZGFwLXRlc3RzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
CgKCAQEAzKJkt2WsALUDA3tQsedx7UJKIxis05+dU5FbBxf/BMSch8gCNh/cWErH
IDljWGwLKbc9UefIz3BzbcNBPLgLGMp7t9Pf9HCBNf7lShLZB2BEGpgpCpd0urox
xTqMEfchssJj75HOZRweHfBDDHk8LMHQYUBn5qTiuMYvBUbPVq69argE/kt5yAEW
COZzzx38a11iY0gtPjY4Tc9vICsLHhTssNn/1wf+GFNzSTHqijC7NKW0txUneFQJ
h6LAmKV/uZC84W1tqMDZKKpABiTpB+JbDvwsb9eXJ6YG6TgbKcrXjLy4ogbIrIRA
s2DqMih792mxusIl6lRf3hTtCdyodwIDAQABo1AwTjAdBgNVHQ4EFgQUnfj9sAq4
2xBbV4rf5FNvYaE2Bg0wHwYDVR0jBBgwFoAUnfj9sAq42xBbV4rf5FNvYaE2Bg0w
DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAFGnBH1qpLJLvrLWKNI5w
u8pFYO3RGqmfJ3BGf60MQxdUaTIUNQxPfPATbth7t8GRJwpWESRDlaXWq9fM9rkt
fbmuqjAMGTFloNd9ra6e2F0CKjwZWcn/3eG/mVw/5d1Ku9Ow8luKrZuzNzVJd13r
hoNc1wYXN0pHWkNiRUuR/E4fE/sn+tYOpJ4XYQvKAcSrNrq8m5O9VG5gLvlTeNno
6q9hBy+5XKYUdHlzbAGm9QL0e1R45Mu4qxcFluKEmzS1rXlLsLs4/pqHgreXlYgL
f7K0cFvaJGnFRKaxa6Bpf1EPNtqSc/pQZh01Ww8CUu1xh2+5KufgJQjAHVG3a1ow
dQ==
-----END CERTIFICATE-----

View file

@ -1,27 +0,0 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAzKJkt2WsALUDA3tQsedx7UJKIxis05+dU5FbBxf/BMSch8gC
Nh/cWErHIDljWGwLKbc9UefIz3BzbcNBPLgLGMp7t9Pf9HCBNf7lShLZB2BEGpgp
Cpd0uroxxTqMEfchssJj75HOZRweHfBDDHk8LMHQYUBn5qTiuMYvBUbPVq69argE
/kt5yAEWCOZzzx38a11iY0gtPjY4Tc9vICsLHhTssNn/1wf+GFNzSTHqijC7NKW0
txUneFQJh6LAmKV/uZC84W1tqMDZKKpABiTpB+JbDvwsb9eXJ6YG6TgbKcrXjLy4
ogbIrIRAs2DqMih792mxusIl6lRf3hTtCdyodwIDAQABAoIBAHQpEucQbe0Q058c
VxhF+2PlJ1R441JV3ubbMkL6mibIvNpO7QJwX5I3EIX4Ta6Z1lRd0g82dcVbXgrG
tbeT+aie+E/Hk++cFZzjDqFXxZ7sRHycN1/tzbNZknsU2wIvuQ9STYxmxjSbG3V/
N3BTOZdmhbVO7Cv/GTwuM+7Y3UWkc74HaXfAgo1UIO9MtqgqP3H1Tv6ZIeKzl+mP
wrvei0eQe6jI4W6+vUOX3SlrlrMxMTLK/Ce2MP1pJx++m8Ga23+vtna+lkOWnwcD
NmhYl4dL31sDcE6Hz/T6Wwfdlfyugw8vi3a3GEYGMIwy27CFf/ccYnWPOI3oIHDe
RwlXLCECgYEA595xJmfUpwqgYY80pT3JG3+64NWJ7f/gH0Ey9fivZfnTegjkI2Kc
Uf7+odCq9I1TFtx10M72N4pXT1uLzJtINYty4ZIfOLG7jSraVbOuf9AvMNCYw+cT
Fcf/HGUJEE95TKYDrGfklOYFNs3ZCcKOCYJOWCuwki8Vm2vtJpV6gnkCgYEA4e5b
DI+YworLjokY8eP4aOF5BMuiFdGkYDjVQZG45RwjJdLwBjaf+HA4pAuJAr2LWiLX
cdKpk+3AlJ8UMLIM+hBP4hBqnrPaRTkEhTXpbUA1lvL9o0mVDFgNh90guu5TeJza
sW7JLaStmAyCxYGxbW4LTjR8GX9DPOPmLs5ZRm8CgYAyFW5DaXIZksYJzLEGcE4c
Tn7DSdy9N+PlXGPxlYHteQUg+wKsUgSKAZZmxXfn0w77hSs9qzar0IoDbjbIP1Jd
nn12E+YCjQGCAJugn2s12HYZCTW2Oxd4QPbt3zUR/NiqocFxYA+TygueRuB2pzue
+jKKAQXmzZzRMYLMLsWDoQKBgAnrCcoyX5VivG7ka9jqlhQcmdBxFAt7KYkj1ZDM
Ud6U7qIRcYIEUd95JbNl4jzhj0WEtAqGIfWhgUvE9ADzQAiWQLt+1v9ii9lwGFe0
tyuZnwCiaCoL5+Qj1Ww6c95g6f8oe51AbMp5KTm8it0axWw1YX+sZCpGYPBCXO9/
FYI3AoGBAMacjjbPjjfOXxBRhRz1rEDTrIStDj5KM4fgslKVGysqpH/mw7gSC8SK
qn0anL2s3SAe9PQpOzM3pFFRZx4XMOk4ojYRZtp3FjPFDRnYuYzkfkbU7eV04awO
6nrua8KNLNK+ir9iCi46tP6Zr3F81zWGUoVArVUgCRDbA9e0swB0
-----END RSA PRIVATE KEY-----

View file

@ -1,8 +0,0 @@
-----BEGIN DH PARAMETERS-----
MIIBCAKCAQEAx5y2viJKOAAcDYSj55odZsbA7dkSQ9afEPd9uaCLOvRYKLJY1S1V
C4m1eVfna8JndSLdsBGDQe4BlBTkEYMYR8CJHtUuBxeAucOH8KlF8rIHXXi71oex
T7kPtJEDINQKOn06bHqNcn0a7ZMWP8jiQ708OYr5P+1T/N82QTAFpDuqK42ZnBqf
8qzQkkTN0UCktY2EWnFTbNIXcMKWQnYP8zt/CG3Q31b2bnQt2iLEa/DIF7RLNjfx
9wPQBBAqgWbLmWfdPpHsAPtQxtItb+GRbPs3aLm06CFKlQuteDoP+suo0EtglHcV
V9Ynvdz0cdJCJ7EPyET6CtLMzc/Puup/AwIBAg==
-----END DH PARAMETERS-----

View file

@ -1,18 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIC3DCCAcSgAwIBAgIJANsmsx7hUWnHMA0GCSqGSIb3DQEBCwUAMBUxEzARBgNV
BAMMCmxkYXAtdGVzdHMwHhcNMTcwNDEyMjAxNzI5WhcNNDQwODI4MjAxNzI5WjAU
MRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
AoIBAQDlWGC5X/TWgysEimM7n0hSkXRCITwAFxKG0C4EeppmL42DBcjQa0xrElRF
h57EBZltbSfvTMDBZAyhx5oZKoETDfwy5jFzf4L4PazSkvfn4qWmCnrq4HNO5Vl7
GBsW93bljsh2nfvoKDX2vBpEUe0qrZzJtRHq0ytfd6zXZ9+WFMsmhD9poADrH4hB
/UOV3uCJPybOoy/WsANQpSgJPD886zakmF+54XQ3tExKzFA1rR4HJbU26h99U5kH
346sV7/xKJLENQVIH1qsqyA1UPDZRWusABjdIPc9Racy0/MxTVE0k5lQbBvz9QSe
HZvW+ct/aZX5tjxr9JlSY7tK2I9FAgMBAAGjMDAuMAkGA1UdEwQCMAAwCwYDVR0P
BAQDAgXgMBQGA1UdEQQNMAuCCWxvY2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAQEA
RZp/fNjoQNaO6KW0Ay0aaPW6jPrcqGjzFgeIXaw/0UaWm5jhptWtjOAILV+afIrd
4cKDg65o4xRdQYYbqmutFMAO/DeyDyMi3IL60qk0osipPDIORx5Ai2ZBQvUsGtwV
np9UwQGNO5AGeR9N5kndyldbpxaIJFhsKOV8uRSi+4PRbMH3G0kJIX6wwZU4Ri/k
3lWJQfqULH0vtMQCWSJuaYHxWYFq4AM+H/zpLwg1WG2eKVgSMWotxMRi5LOFSBbG
XuOxAb0SNBcXl6kjRYbQyHBxIJMsB1lk64g7dTJqXuYFUwmIGL/vTr6PL6EKYk65
/aWO8cvwXOrYaf9umgcqvg==
-----END CERTIFICATE-----

View file

@ -1,27 +0,0 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA5VhguV/01oMrBIpjO59IUpF0QiE8ABcShtAuBHqaZi+NgwXI
0GtMaxJURYeexAWZbW0n70zAwWQMoceaGSqBEw38MuYxc3+C+D2s0pL35+Klpgp6
6uBzTuVZexgbFvd25Y7Idp376Cg19rwaRFHtKq2cybUR6tMrX3es12fflhTLJoQ/
aaAA6x+IQf1Dld7giT8mzqMv1rADUKUoCTw/POs2pJhfueF0N7RMSsxQNa0eByW1
NuoffVOZB9+OrFe/8SiSxDUFSB9arKsgNVDw2UVrrAAY3SD3PUWnMtPzMU1RNJOZ
UGwb8/UEnh2b1vnLf2mV+bY8a/SZUmO7StiPRQIDAQABAoIBAQDHBbKqK4MkxB8I
ia8jhk4UmPTyjjSrP1pscyv75wkltA5xrQtfEj32jKlkzRQRt2o1c4w8NbbwHAp6
OeSYAjKQfoplAS3YtMbK9XqMIc3QBPcK5/1S5gQqaw0DrR+VBpq/CvEbPm3kQUDT
JNkGgLH3X0G4KNGrniT9a7UqGJIGgdBAr7bPESiDi9wuOwfhm/9TB8LOG8wB9cn4
NcUipvjOcRxMFkyYtq056ZfGeoK2ooFe0lHi4j8sWXfII789OqN0plecAg8NGZsl
klSncpTObE6eTXo9Jncio3pftvszEctKssK7vuL6opajtppT6C5FnKLb6NIAOo7j
CPk1BRPhAoGBAPf8TMTr+l8MHRuVXEx52E1dBH46ZB8bMfvwb7cZ31Fn0EEmygCj
wP9eKZ8MKmHVBbU6CbxYQMICTTwRrw9H0tNoaZBwzWMz/JDHcACfsPKtfrX8T4UQ
wmVwbLctdC1Cbaxn1jYeSLoLfSe8IGPDnLpsMCzpRcQIgPS+gO69zr8vAoGBAOzB
254TKd2OQPnvUvmAVYGRYyTu/+ShH9fZyDJYtjhQbuxt6eqh3poneWJOW+KPlqDd
J0a8yv1pDXmCy5k1Oo8Nubt7cPI0y2z0nm5LvAaqPaFdUJs9nq9umH3svJh6du6Z
+TZ6MDU/eyJRq7Mc5SQrssziJidS3cU21b560xvLAoGBAPYpZY9Ia7Uz0iUSY5eq
j7Nj9VTT45UZKsnbRxnrvckSEyDJP1XZN3iG4Sv3KI8KpWrbHNTwif/Lxx0stKin
dDjU+Y0e3FJwRXL19lE4M68B17kQp2MAWufU7KX8oclXmoS8YmBAOZMsWmU6ErDV
eVt4j23VdaJ9inzoKhZTJcqTAoGAH9znJZsGo16lt/1ReWqgF1Ptt+bCYY6drnsM
ylnODD4m74LLXFx0jOKLH4PUMeWJLBUXWBnIZ9pfid7kb7YOL3p1aJnwVWhtiDhT
qhxfLbZznOfmFT5xwMJtm2Tk7NBueSYXuBExs7jbZX8AUJau7/NBmPlGkTxBxGzg
z0XQa4kCgYBxYBXwFpLLjBO+bMMkoVOlMDj7feCOWP9CsnKQSHYqPbmmb+8mA7pN
mIWfjSVynVe+Ncn0I5Uijbs9QDYqcfApJQ+iXeb+VGrg4QkLHHGd/5kIY28Evc6A
KVyRIuiYNmgOXGpaFpMXSw718N4U7jWW7lqUxK2rvEupFhaL52oJFQ==
-----END RSA PRIVATE KEY-----

Some files were not shown because too many files have changed in this diff Show more