Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 2
+32
View File
@@ -0,0 +1,32 @@
# EditorConfig is awesome: http://EditorConfig.org
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
charset = utf-8
indent_style = tab
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true
; Python: PEP8 defines 4 spaces for indentation
[*.py]
indent_style = space
indent_size = 4
; YAML format, 2 spaces
[*.{yaml,yml,yaml.in,json}]
indent_style = space
indent_size = 2
; HTML, CSS and JavaScript, 4 spaces
[*.{html,css,js,ts,tsx,jsx}]
charset = utf-8
indent_size = 2
indent_style = space
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
+26
View File
@@ -0,0 +1,26 @@
.*
# We don't want to ignore the following even if they are dot-files
!.editorconfig
!.gitattributes
!.gitignore
!.gitlab-ci.yml
!.env
!.chglog
!.dependabot.yml
!.github
/vendor
/bin
/cmd/licod/debug
/test/tests.*
/test/coverage.*
/golint.txt
/govet.txt
/dist
/examples
/identifier/node_modules
/Caddyfile
/3rdparty-LICENSES.md
/identifier-registration.yaml
/scopes.yaml
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
# Example Caddyfile to use with https://caddyserver.com
#
# This assumes Konnect is running with identifier on 127.0.0.1:8777. In addition
# for development, the identifier is used directly from webpack-dev-server
# running on 127.0.0.1:3001. Additional examples are included for third party
# login provides which use cookie passthrough backend.
*:8443 {
errors stderr
log stdout
tls self_signed
# konnect oidc
proxy /.well-known/openid-configuration 127.0.0.1:8777 {
transparent
}
proxy /konnect/v1/jwks.json 127.0.0.1:8777 {
transparent
}
proxy /konnect/v1/token 127.0.0.1:8777 {
transparent
}
proxy /konnect/v1/userinfo 127.0.0.1:8777 {
transparent
}
proxy /konnect/v1/static 127.0.0.1:8777 {
transparent
}
proxy /konnect/v1/session 127.0.0.1:8777 {
transparent
}
proxy /konnect/v1/register 127.0.0.1:8777 {
transparent
}
# konnect identifier development via webpack-dev-server
proxy /signin/v1/ 127.0.0.1:3001 {
header_downstream Cache-Control "no-cache, max-age=0, public"
header_downstream Referrer-Policy origin
header_downstream Content-Security-Policy "object-src 'none'; script-src 'self'; base-uri 'none'; frame-ancestors 'none';"
}
proxy /ws 127.0.0.1:3001 {
websocket
}
proxy /static 127.0.0.1:3001
proxy /signin/v1/identifier/_/ 127.0.0.1:8777 {
transparent
}
# konnect identifier login area
proxy /signin/ 127.0.0.1:8777 {
transparent
}
# third party login area provider example
# proxy /provider/simple 127.0.0.1:8999
# konnect authorize endpoint below third party login area provider
#proxy /provider/simple/konnect/v1/authorize 127.0.0.1:8777 {
# without /provider/simple
# header_upstream X-Forwarded-Prefix /provider/simple
# transparent
#}
# konnect cookieserver, start with python3 ./examples/cookieserver.py 8088
#proxy /cookieserver/simple-userinfo 127.0.0.1:8088
}
+38
View File
@@ -0,0 +1,38 @@
# Example Caddyfile to use with https://caddyserver.com
#
# This assumes Konnect is running with identifier on 127.0.0.1:8777.
*:8443 {
errors stderr
log stdout
tls self_signed
# konnect oidc
proxy /.well-known/openid-configuration 127.0.0.1:8777 {
transparent
}
proxy /konnect/v1/jwks.json 127.0.0.1:8777 {
transparent
}
proxy /konnect/v1/token 127.0.0.1:8777 {
transparent
}
proxy /konnect/v1/userinfo 127.0.0.1:8777 {
transparent
}
proxy /konnect/v1/static 127.0.0.1:8777 {
transparent
}
proxy /konnect/v1/session 127.0.0.1:8777 {
transparent
}
proxy /konnect/v1/register 127.0.0.1:8777 {
transparent
}
# konnect identifier login area
proxy /signin/ 127.0.0.1:8777 {
transparent
}
}
+59
View File
@@ -0,0 +1,59 @@
#
# Copyright 2019 Kopano and its licensors
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License, version 3 or
# later, as published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
FROM golang:1.17.2-buster
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ARG GOLANGCI_LINT_TAG=v1.23.8
RUN curl -sfL \
https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | \
sh -s -- -b /usr/local/bin ${GOLANGCI_LINT_TAG}
RUN GOBIN=/usr/local/bin go get -v \
github.com/tebeka/go2xunit \
github.com/axw/gocov/... \
github.com/AlekSi/gocov-xml \
github.com/wadey/gocovmerge \
&& go clean -cache && rm -rf /root/go
ENV DEBIAN_FRONTEND noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
gettext-base \
imagemagick \
scour \
nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
yarn \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
ENV GOCACHE=/tmp/go-build
ENV GOPATH=""
ENV HOME=/tmp
CMD ["make", "DATE=reproducible"]
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+214
View File
@@ -0,0 +1,214 @@
PACKAGE = github.com/libregraph/lico
PACKAGE_NAME = libregraph-$(shell basename $(PACKAGE))
# Tools
GO ?= go
GOFMT ?= gofmt
GOLINT ?= golangci-lint
DLV ?= dlv
GO2XUNIT ?= go2xunit
GOCOV ?= gocov
GOCOVXML ?= gocov-xml
GOCOVMERGE ?= gocovmerge
CHGLOG ?= git-chglog
# Cgo
CGO_ENABLED ?= 0
# Go modules
GO111MODULE ?= on
# Variables
export CGO_ENABLED GO111MODULE
unexport GOPATH
ARGS ?=
PWD := $(shell dirname $(realpath $(lastword $(MAKEFILE_LIST))))
DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
VERSION ?= $(shell git describe --tags --always --dirty --match=v* 2>/dev/null | sed 's/^v//' || \
cat $(CURDIR)/.version 2> /dev/null || echo 0.0.0-unreleased)
PKGS = $(or $(PKG),$(shell $(GO) list -mod=readonly ./... | grep -v "^$(PACKAGE)/vendor/"))
TESTPKGS = $(shell $(GO) list -mod=readonly -f '{{ if or .TestGoFiles .XTestGoFiles }}{{ .ImportPath }}{{ end }}' $(PKGS) 2>/dev/null)
CMDS = $(or $(CMD),$(addprefix cmd/,$(notdir $(shell find "$(PWD)/cmd/" -type d))))
TIMEOUT = 30
GOLINT_ARGS ?= --new
# Debug variables
DLV_APIVERSION ?= 2
DLV_ARGS ?=
DLV_EXECUTABLE ?= bin/licod
DLV_ATTACH_PID ?= $(shell pgrep -f $(DLV_EXECUTABLE))
# Build
LDFLAGS ?= -s -w
ASMFLAGS ?=
GCFLAGS ?=
.PHONY: all
all: vendor | $(CMDS) identifier-webapp
.PHONY: commands
commands: $(CMDS)
.PHONY: $(CMDS)
$(CMDS): vendor ; $(info building $@ ...) @
$(GO) build \
-mod vendor \
-trimpath \
-tags release \
-buildmode=exe \
-asmflags '$(ASMFLAGS)' \
-gcflags '$(GCFLAGS)' \
-ldflags '$(LDFLAGS) -buildid=reproducible/$(VERSION) -X $(PACKAGE)/version.Version=$(VERSION) -X $(PACKAGE)/version.BuildDate=$(DATE) -extldflags -static' \
-o bin/$(notdir $@) ./$@
.PHONY: identifier-webapp
identifier-webapp:
$(MAKE) -C identifier build
# Helpers
.PHONY: lint
lint: vendor ; $(info running $(GOLINT) ...) @
$(GOLINT) run $(GOLINT_ARGS)
$(MAKE) -C identifier lint
.PHONY: lint-checkstyle
lint-checkstyle: vendor ; $(info running $(GOLINT) checkstyle ...) @
@mkdir -p test
$(GOLINT) run $(GOLINT_ARGS) --out-format checkstyle --issues-exit-code 0 > test/tests.lint.xml
$(MAKE) -C identifier lint-checkstyle
.PHONY: fulllint
fulllint: GOLINT_ARGS=
fulllint: lint
.PHONY: fmt
fmt: ; $(info running gofmt ...) @
@ret=0 && for d in $$($(GO) list -mod=readonly -f '{{.Dir}}' ./... | grep -v /vendor/); do \
$(GOFMT) -l -w $$d/*.go || ret=$$? ; \
done ; exit $$ret
.PHONY: check
check: ; $(info checking dependencies ...) @
@$(GO) mod verify && echo OK
# Tests
TEST_TARGETS := test-default test-bench test-short test-race test-verbose
.PHONY: $(TEST_TARGETS)
test-bench: ARGS=-run=_Bench* -test.benchmem -bench=.
test-short: ARGS=-short
test-race: ARGS=-race
test-race: CGO_ENABLED=1
test-verbose: ARGS=-v
$(TEST_TARGETS): NAME=$(MAKECMDGOALS:test-%=%)
$(TEST_TARGETS): test
.PHONY: test
test: ; $(info running $(NAME:%=% )tests ...) @
@CGO_ENABLED=1 $(GO) test -timeout $(TIMEOUT)s $(ARGS) $(TESTPKGS)
TEST_XML_TARGETS := test-xml-default test-xml-short test-xml-race
.PHONY: $(TEST_XML_TARGETS)
test-xml-short: ARGS=-short
test-xml-race: ARGS=-race
test-xml-race: CGO_ENABLED=1
$(TEST_XML_TARGETS): NAME=$(MAKECMDGOALS:test-%=%)
$(TEST_XML_TARGETS): test-xml
.PHONY: test-xml
test-xml: ; $(info running $(NAME:%=% )tests ...) @
@mkdir -p test
2>&1 CGO_ENABLED=1 $(GO) test -timeout $(TIMEOUT)s $(ARGS) -v $(TESTPKGS) | tee test/tests.output
test -s test/tests.output && $(GO2XUNIT) -fail -input test/tests.output -output test/tests.xml
COVERAGE_PROFILE = $(COVERAGE_DIR)/profile.out
COVERAGE_XML = $(COVERAGE_DIR)/coverage.xml
COVERAGE_HTML = $(COVERAGE_DIR)/coverage.html
.PHONY: test-coverage
test-coverage: COVERAGE_DIR := $(CURDIR)/test/coverage.$(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
test-coverage: ; $(info running coverage tests ...)
@mkdir -p $(COVERAGE_DIR)/coverage
@rm -f test/tests.output
@for pkg in $(TESTPKGS); do \
CGO_ENABLED=1 $(GO) test -timeout $(TIMEOUT)s -v \
-coverpkg=$$($(GO) list -mod=readonly -f '{{ join .Deps "\n" }}' $$pkg | \
grep '^$(PACKAGE)/' | grep -v '^$(PACKAGE)/vendor/' | \
tr '\n' ',')$$pkg \
-covermode=atomic \
-coverprofile="$(COVERAGE_DIR)/coverage/`echo $$pkg | tr "/" "-"`.cover" $$pkg | tee -a test/tests.output ;\
done
@$(GO2XUNIT) -fail -input test/tests.output -output test/tests.xml
@$(GOCOVMERGE) $(COVERAGE_DIR)/coverage/*.cover > $(COVERAGE_PROFILE)
@$(GO) tool cover -html=$(COVERAGE_PROFILE) -o $(COVERAGE_HTML)
@$(GOCOV) convert $(COVERAGE_PROFILE) | $(GOCOVXML) > $(COVERAGE_XML)
# Debug
.PHONY: dlv
dlv: ; $(info attaching Delve debugger ...)
$(DLV) attach --api-version=$(DLV_APIVERSION) $(DLV_ARGS) $(DLV_ATTACH_PID) $(DLV_EXECUTABLE)
# Mod
.PHONY: go.sum
go.sum: go.mod ; $(info updating dependencies ...)
@$(GO) mod tidy -v
@touch $@
.PHONY: vendor
vendor: go.mod ; $(info retrieving dependencies ...)
@$(GO) mod vendor -v
@touch $@
# Dist
.PHONY: licenses
licenses: vendor ; $(info building licenses files ...)
$(CURDIR)/scripts/go-license-ranger.py > $(CURDIR)/3rdparty-LICENSES.md
make -s -C identifier licenses >> $(CURDIR)/3rdparty-LICENSES.md
3rdparty-LICENSES.md: licenses
.PHONY: dist
dist: 3rdparty-LICENSES.md ; $(info building dist tarball ...)
@rm -rf "dist/${PACKAGE_NAME}-${VERSION}"
@mkdir -p "dist/${PACKAGE_NAME}-${VERSION}"
@mkdir -p "dist/${PACKAGE_NAME}-${VERSION}/scripts"
@cd dist && \
cp -avf ../LICENSE.txt "${PACKAGE_NAME}-${VERSION}" && \
cp -avf ../README.md "${PACKAGE_NAME}-${VERSION}" && \
cp -avf ../3rdparty-LICENSES.md "${PACKAGE_NAME}-${VERSION}" && \
cp -avf ../*.yaml.in "${PACKAGE_NAME}-${VERSION}" && \
cp -avf ../bin/* "${PACKAGE_NAME}-${VERSION}" && \
cp -avr ../identifier/build "${PACKAGE_NAME}-${VERSION}/identifier-webapp" && \
cp -avf ../scripts/licod.binscript "${PACKAGE_NAME}-${VERSION}/scripts" && \
cp -avf ../scripts/licod.service "${PACKAGE_NAME}-${VERSION}/scripts" && \
cp -avf ../scripts/licod.cfg "${PACKAGE_NAME}-${VERSION}/scripts" && \
tar --owner=0 --group=0 -czvf ${PACKAGE_NAME}-${VERSION}.tar.gz "${PACKAGE_NAME}-${VERSION}" && \
cd ..
.PHONE: changelog
changelog: ; $(info updating changelog ...)
$(CHGLOG) --output CHANGELOG.md $(ARGS)
# Rest
.PHONY: clean
clean: ; $(info cleaning ...) @
@rm -rf bin
@rm -rf test/test.*
@$(MAKE) -C identifier clean
.PHONY: version
version:
@echo $(VERSION)
+5
View File
@@ -0,0 +1,5 @@
LibreGraph Connect
Copyright 2017-2021 Kopano and its licensors
This product includes software developed at
Kopano (https://kopano.com/).
+242
View File
@@ -0,0 +1,242 @@
# LibreGraph Connect
LibreGraph Connect implements an [OpenID provider](http://openid.net/specs/openid-connect-core-1_0.html)
(OP) with integrated web login and consent forms.
[![Go Report Card](https://goreportcard.com/badge/github.com/libregraph/lico)](https://goreportcard.com/report/github.com/libregraph/lico)
LibreGraph Connect has it origin in Kopano Konnect and is meant as its vendor
agnostic successor.
## Technologies
- Go
- React
## Standards supported by Lico
Lico provides services based on open standards. To get you an idea what
Lico can do and how you could use it, this section lists the
[OpenID Connect](https://openid.net/connect/) standards which are implemented.
- https://openid.net/specs/openid-connect-core-1_0.html
- https://openid.net/specs/openid-connect-discovery-1_0.html
- https://openid.net/specs/openid-connect-frontchannel-1_0.html
- https://openid.net/specs/openid-connect-session-1_0.html
- https://openid.net/specs/openid-connect-registration-1_0.html
Furthermore the following extensions/base specifications extend, define and
combine the implementation details.
- https://tools.ietf.org/html/rfc6749
- https://tools.ietf.org/html/rfc7517
- https://tools.ietf.org/html/rfc7519
- https://tools.ietf.org/html/rfc7636
- https://tools.ietf.org/html/rfc7693
- https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html
- https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html
- https://www.iana.org/assignments/jose/jose.xhtml
- https://nacl.cr.yp.to/secretbox.html
## Build dependencies
Make sure you have Go 1.18 or later installed. This project uses Go Modules.
Lico also includes a modern web app which requires a couple of additional
build dependencies which are furthermore also assumed to be in your $PATH.
- yarn - [Yarn](https://yarnpkg.com)
- convert, identify - [Imagemagick](https://www.imagemagick.org)
- scour - [Scour](https://github.com/scour-project/scour)
To build Lico, a `Makefile` is provided, which requires [make](https://www.gnu.org/software/make/manual/make.html).
When building, third party dependencies will tried to be fetched from the Internet
if not there already.
## Building from source
```
git clone <THIS-PROJECT> lico
cd lico
make
```
### Optional build dependencies
Some optional build dependencies are required for linting and continuous
integration. Those tools are mostly used by make to perform various tasks and
are expected to be found in your $PATH.
- golangci-lint - [golangci-lint](https://github.com/golangci/golangci-lint)
- go2xunit - [go2xunit](https://github.com/tebeka/go2xunit)
- gocov - [gocov](https://github.com/axw/gocov)
- gocov-xml - [gocov-xml](https://github.com/AlekSi/gocov-xml)
- gocovmerge - [gocovmerge](https://github.com/wadey/gocovmerge)
### Build with Docker
```
docker build -t licod-builder -f Dockerfile.build .
docker run -it --rm -u $(id -u):$(id -g) -v $(pwd):/build licod-builder
```
## Running Lico
Lico can provide user login based on available backends.
All backends require certain general parameters to be present. Create a RSA
key-pair file with `openssl genpkey -algorithm RSA -out private-key.pem -pkeyopt rsa_keygen_bits:4096`
and provide the key file with the `--signing-private-key` parameter. Lico can
load PEM encoded PKCS#1 and PKCS#8 key files and JSON Web Keys from `.json` files
If you skip this, Lico will create a random non-persistent RSA key on startup.
To encrypt certain values, Lico needs a secure encryption key. Create a
suitable key of 32 bytes with `openssl rand -out encryption.key 32` and provide
the full path to that file via the `--encryption-secret` parameter. If you skip
this, Lico will generate a random key on startup.
To run a functional OpenID Connect provider, an issuer identifier is required.
The `iss` is a full qualified https:// URI pointing to the web server which
serves the requests to Lico (example: https://example.com). Provide the
Issuer Identifier with the `--iss` parametter when starting Lico.
Furthermore to allow clients to utilize the Lico services, clients need to
be known/registered. For now Lico uses a static configuration file which
allows clients and their allowed urls to be registered. See the the example at
`identifier-registration.yaml.in`. Copy and modify that file to include all
the clients which should be able to use OpenID Connect and/or OAuth2 and start
Lico with the `--identifier-registration-conf` parameter pointing to that
file. Without any explicitly registered clients, Lico will only accept clients
which redirect to an URI which starts with the value provided with the `--iss`
parameter.
### Lico cryptography and validation
A tool can be used to create keys for Lico and also to validate tokens to
ensure correct operation is [Step CLI](https://github.com/smallstep/cli). This
helps since OpenSSL is not able to create or validate all of the different key
formats, ciphers and curves which are supported by Lico.
Here are some examples relevant for Lico.
```
step crypto keypair 1-rsa.pub 1-rsa.pem \
--kty RSA --size 4096 --no-password --insecure
```
```
step crypto keypair 1-ecdsa-p-256.pub 1-ecdsa-p-256.pem \
--kty EC --curve P-256 --no-password --insecure
```
```
step crypto jwk create 1-eddsa-ed25519.pub.json 1-eddsa-ed25519.key.json \
-kty OKP --crv Ed25519 --no-password --insecure
```
```
echo $TOKEN_VALUE | step crypto jwt verify --iss $ISS \
--aud playground-trusted.js --jwks $ISS/konnect/v1/jwks.json
```
### URL endpoints
Take a look at `Caddyfile.example` on the URL endpoints provided by Lico and
how to expose them through a TLS proxy.
The base URL of the frontend proxy is what will become the value of the `--iss`
parameter when starting up Lico. OIDC requires the Issuer Identifier to be
secure (https:// required).
### LibreGraph backend
Generic backend support is available through the LibreGraph API. Any service can
provide the required endpoints and Lico connects to them.
```
export LIBREGRAPH_URI=http://your-backend.local:5050
bin/licod serve --listen=127.0.0.1:8777 \
--iss=https://mylico.local \
libregraph
```
### LDAP backend
This assumes that Lico can directly connect to an LDAP server via TCP.
```
export LDAP_URI=ldap://myldap.local:389
export LDAP_BINDDN="cn=admin,dc=example,dc=local"
export LDAP_BINDPW="its-a-secret"
export LDAP_BASEDN="dc=example,dc=local"
export LDAP_SCOPE=sub
export LDAP_LOGIN_ATTRIBUTE=uid
export LDAP_EMAIL_ATTRIBUTE=mail
export LDAP_NAME_ATTRIBUTE=cn
export LDAP_UUID_ATTRIBUTE=uidNumber
export LDAP_UUID_ATTRIBUTE_TYPE=text
export LDAP_FILTER="(objectClass=organizationalPerson)"
bin/licod serve --listen=127.0.0.1:8777 \
--iss=https://mylico.local \
ldap
```
### Build Lico Docker image
This project includes a `Dockerfile` which can be used to build a Docker
container from the locally build version. Similarly the `Dockerfile.release`
builds the Docker image locally from the latest release download.
```
docker build -t licod .
```
```
docker build -f Dockerfile.release -t licod .
```
## Run unit tests
```
make test
```
## Development
As Lico includes a web application (identifier), a `Caddyfile.dev` file is
provided which exposes the identifier's web application directly via a
webpack dev server.
### Debugging
Lico is built stripped and without debug symbols by default. To build for
debugging, compile with additional environment variables which override/reset
build optimization like this
```
LDFLAGS="" GCFLAGS="all=-N -l" ASMFLAGS="" make cmd/licod
```
The resulting binary is not stripped and sutiable to be debugged with [Delve](https://github.com/go-delve/delve).
To connect Delve to a running Lico binary you can use the `make dlv` command.
Control its behavior via `DLV_*` environment variables. See the `Makefile` source
for details.
```
DLV_ARGS= make dlv
```
#### Remote debugging
To use remote debugging, pass additional args like this.
```
DLV_ARGS=--listen=:2345 make dlv
```
## License
See `LICENSE.txt` for licensing information of this project.
@@ -0,0 +1,53 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package bsguest
import (
"github.com/libregraph/lico/bootstrap"
"github.com/libregraph/lico/identity"
"github.com/libregraph/lico/identity/managers"
)
// Identity managers.
const (
identityManagerName = "guest"
)
func Register() error {
return bootstrap.RegisterIdentityManager(identityManagerName, NewIdentityManager)
}
func MustRegister() {
if err := Register(); err != nil {
panic(err)
}
}
func NewIdentityManager(bs bootstrap.Bootstrap) (identity.Manager, error) {
config := bs.Config()
logger := config.Config.Logger
identityManagerConfig := &identity.Config{
Logger: logger,
}
guestIdentityManager := managers.NewGuestIdentityManager(identityManagerConfig)
return guestIdentityManager, nil
}
@@ -0,0 +1,175 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package bsldap
import (
"crypto/x509"
"fmt"
"io/ioutil"
"os"
"strings"
"github.com/libregraph/lico/bootstrap"
"github.com/libregraph/lico/identifier"
"github.com/libregraph/lico/identifier/backends/ldap"
"github.com/libregraph/lico/identity"
"github.com/libregraph/lico/identity/managers"
)
// Identity managers.
const (
identityManagerName = "ldap"
)
func Register() error {
return bootstrap.RegisterIdentityManager(identityManagerName, NewIdentityManager)
}
func MustRegister() {
if err := Register(); err != nil {
panic(err)
}
}
func NewIdentityManager(bs bootstrap.Bootstrap) (identity.Manager, error) {
config := bs.Config()
logger := config.Config.Logger
if config.AuthorizationEndpointURI.String() != "" {
return nil, fmt.Errorf("ldap backend is incompatible with authorization-endpoint-uri parameter")
}
config.AuthorizationEndpointURI.Path = bs.MakeURIPath(bootstrap.APITypeSignin, "/identifier/_/authorize")
if config.EndSessionEndpointURI.String() != "" {
return nil, fmt.Errorf("ldap backend is incompatible with endsession-endpoint-uri parameter")
}
config.EndSessionEndpointURI.Path = bs.MakeURIPath(bootstrap.APITypeSignin, "/identifier/_/endsession")
if config.SignInFormURI.EscapedPath() == "" {
config.SignInFormURI.Path = bs.MakeURIPath(bootstrap.APITypeSignin, "/identifier")
}
if config.SignedOutURI.EscapedPath() == "" {
config.SignedOutURI.Path = bs.MakeURIPath(bootstrap.APITypeSignin, "/goodbye")
}
// Default LDAP attribute mappings.
attributeMapping := map[string]string{
ldap.AttributeLogin: os.Getenv("LDAP_LOGIN_ATTRIBUTE"),
ldap.AttributeEmail: os.Getenv("LDAP_EMAIL_ATTRIBUTE"),
ldap.AttributeName: os.Getenv("LDAP_NAME_ATTRIBUTE"),
ldap.AttributeFamilyName: os.Getenv("LDAP_FAMILY_NAME_ATTRIBUTE"),
ldap.AttributeGivenName: os.Getenv("LDAP_GIVEN_NAME_ATTRIBUTE"),
ldap.AttributeUUID: os.Getenv("LDAP_UUID_ATTRIBUTE"),
fmt.Sprintf("%s_type", ldap.AttributeUUID): os.Getenv("LDAP_UUID_ATTRIBUTE_TYPE"),
}
// Add optional LDAP attribute mappings.
if numericUIDAttribute := os.Getenv("LDAP_UIDNUMBER_ATTRIBUTE"); numericUIDAttribute != "" {
attributeMapping[ldap.AttributeNumericUID] = numericUIDAttribute
}
// Sub from LDAP attribute mappings.
var subMapping []string
if subMappingString := os.Getenv("LDAP_SUB_ATTRIBUTES"); subMappingString != "" {
subMapping = strings.Split(subMappingString, " ")
}
// Use a clone here to avoid changing the config of other possible users of the config.
tlsConfig := config.TLSClientConfig.Clone()
if caCertFile := os.Getenv("LDAP_TLS_CACERT"); caCertFile != "" {
if pemBytes, err := ioutil.ReadFile(caCertFile); err == nil {
rpool, _ := x509.SystemCertPool()
if rpool.AppendCertsFromPEM(pemBytes) {
tlsConfig.RootCAs = rpool
} else {
return nil, fmt.Errorf("failed to append CA certificate(s) from '%s' to pool", caCertFile)
}
} else {
return nil, fmt.Errorf("failed to read CA certificate(s) from '%s': %w", caCertFile, err)
}
}
identifierBackend, identifierErr := ldap.NewLDAPIdentifierBackend(
config.Config,
tlsConfig,
os.Getenv("LDAP_URI"),
os.Getenv("LDAP_BINDDN"),
os.Getenv("LDAP_BINDPW"),
os.Getenv("LDAP_BASEDN"),
os.Getenv("LDAP_SCOPE"),
os.Getenv("LDAP_FILTER"),
subMapping,
attributeMapping,
)
if identifierErr != nil {
return nil, fmt.Errorf("failed to create identifier backend: %v", identifierErr)
}
fullAuthorizationEndpointURL := bootstrap.WithSchemeAndHost(config.AuthorizationEndpointURI, config.IssuerIdentifierURI)
fullSignInFormURL := bootstrap.WithSchemeAndHost(config.SignInFormURI, config.IssuerIdentifierURI)
fullSignedOutEndpointURL := bootstrap.WithSchemeAndHost(config.SignedOutURI, config.IssuerIdentifierURI)
activeIdentifier, err := identifier.NewIdentifier(&identifier.Config{
Config: config.Config,
BaseURI: config.IssuerIdentifierURI,
PathPrefix: bs.MakeURIPath(bootstrap.APITypeSignin, ""),
StaticFolder: config.IdentifierClientPath,
ScopesConf: config.IdentifierScopesConf,
WebAppDisabled: config.IdentifierClientDisabled,
LogonCookieName: "__Secure-KKT", // Kopano-Konnect-Token
LogonCookieSameSite: config.CookieSameSite,
ConsentCookieSameSite: config.CookieSameSite,
StateCookieSameSite: config.CookieSameSite,
AuthorizationEndpointURI: fullAuthorizationEndpointURL,
SignedOutEndpointURI: fullSignedOutEndpointURL,
DefaultBannerLogo: config.IdentifierDefaultBannerLogo,
DefaultSignInPageText: config.IdentifierDefaultSignInPageText,
DefaultSignInPageLogoURI: config.IdentifierDefaultLogoTargetURI,
DefaultUsernameHintText: config.IdentifierDefaultUsernameHintText,
UILocales: config.IdentifierUILocales,
Backend: identifierBackend,
})
if err != nil {
return nil, fmt.Errorf("failed to create identifier: %v", err)
}
err = activeIdentifier.SetKey(config.EncryptionSecret)
if err != nil {
return nil, fmt.Errorf("invalid --encryption-secret parameter value for identifier: %v", err)
}
identityManagerConfig := &identity.Config{
SignInFormURI: fullSignInFormURL,
SignedOutURI: fullSignedOutEndpointURL,
Logger: logger,
ScopesSupported: config.Config.AllowedScopes,
}
identifierIdentityManager := managers.NewIdentifierIdentityManager(identityManagerConfig, activeIdentifier)
logger.Infoln("using identifier backed identity manager")
return identifierIdentityManager, nil
}
@@ -0,0 +1,158 @@
/*
* Copyright 2021 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package bslibregraph
import (
"fmt"
"os"
"strings"
"github.com/cevaris/ordered_map"
"github.com/libregraph/lico/bootstrap"
"github.com/libregraph/lico/identifier"
"github.com/libregraph/lico/identifier/backends/libregraph"
"github.com/libregraph/lico/identity"
identityClients "github.com/libregraph/lico/identity/clients"
"github.com/libregraph/lico/identity/managers"
)
// Identity managers.
const (
identityManagerName = "libregraph"
)
func Register() error {
return bootstrap.RegisterIdentityManager(identityManagerName, NewIdentityManager)
}
func MustRegister() {
if err := Register(); err != nil {
panic(err)
}
}
func NewIdentityManager(bs bootstrap.Bootstrap) (identity.Manager, error) {
config := bs.Config()
logger := config.Config.Logger
if config.AuthorizationEndpointURI.String() != "" {
return nil, fmt.Errorf("libregraph backend is incompatible with authorization-endpoint-uri parameter")
}
config.AuthorizationEndpointURI.Path = bs.MakeURIPath(bootstrap.APITypeSignin, "/identifier/_/authorize")
if config.EndSessionEndpointURI.String() != "" {
return nil, fmt.Errorf("libregraph backend is incompatible with endsession-endpoint-uri parameter")
}
config.EndSessionEndpointURI.Path = bs.MakeURIPath(bootstrap.APITypeSignin, "/identifier/_/endsession")
if config.SignInFormURI.EscapedPath() == "" {
config.SignInFormURI.Path = bs.MakeURIPath(bootstrap.APITypeSignin, "/identifier")
}
if config.SignedOutURI.EscapedPath() == "" {
config.SignedOutURI.Path = bs.MakeURIPath(bootstrap.APITypeSignin, "/goodbye")
}
defaultURI := os.Getenv("LIBREGRAPH_URI")
var scopedURIs *ordered_map.OrderedMap
if scopedURIsString := os.Getenv("LIBREGRAPH_SCOPED_URIS"); scopedURIsString != "" {
scopedURIs = ordered_map.NewOrderedMap()
// Format is <scope>:<url>,<scope>:<url>,...
for _, v := range strings.Split(scopedURIsString, ",") {
parts := strings.SplitN(v, ":", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("failed to parse scoped URIs, format invalid")
}
scopedURIs.Set(parts[0], parts[1])
}
}
var clients *identityClients.Registry
if clientsRecord, ok := bs.Managers().Get("clients"); ok {
clients = clientsRecord.(*identityClients.Registry)
} else {
return nil, fmt.Errorf("clients manager not found but is required")
}
identifierBackend, identifierErr := libregraph.NewLibreGraphIdentifierBackend(
config.Config,
config.TLSClientConfig,
defaultURI,
scopedURIs,
clients,
)
if identifierErr != nil {
return nil, fmt.Errorf("failed to create identifier backend: %v", identifierErr)
}
fullAuthorizationEndpointURL := bootstrap.WithSchemeAndHost(config.AuthorizationEndpointURI, config.IssuerIdentifierURI)
fullSignInFormURL := bootstrap.WithSchemeAndHost(config.SignInFormURI, config.IssuerIdentifierURI)
fullSignedOutEndpointURL := bootstrap.WithSchemeAndHost(config.SignedOutURI, config.IssuerIdentifierURI)
activeIdentifier, err := identifier.NewIdentifier(&identifier.Config{
Config: config.Config,
BaseURI: config.IssuerIdentifierURI,
PathPrefix: bs.MakeURIPath(bootstrap.APITypeSignin, ""),
StaticFolder: config.IdentifierClientPath,
ScopesConf: config.IdentifierScopesConf,
WebAppDisabled: config.IdentifierClientDisabled,
LogonCookieName: "__Secure-KKT", // Kopano-Konnect-Token
LogonCookieSameSite: config.CookieSameSite,
ConsentCookieSameSite: config.CookieSameSite,
StateCookieSameSite: config.CookieSameSite,
AuthorizationEndpointURI: fullAuthorizationEndpointURL,
SignedOutEndpointURI: fullSignedOutEndpointURL,
DefaultBannerLogo: config.IdentifierDefaultBannerLogo,
DefaultSignInPageText: config.IdentifierDefaultSignInPageText,
DefaultSignInPageLogoURI: config.IdentifierDefaultLogoTargetURI,
DefaultUsernameHintText: config.IdentifierDefaultUsernameHintText,
UILocales: config.IdentifierUILocales,
Backend: identifierBackend,
})
if err != nil {
return nil, fmt.Errorf("failed to create identifier: %v", err)
}
err = activeIdentifier.SetKey(config.EncryptionSecret)
if err != nil {
return nil, fmt.Errorf("invalid --encryption-secret parameter value for identifier: %v", err)
}
identityManagerConfig := &identity.Config{
SignInFormURI: fullSignInFormURL,
SignedOutURI: fullSignedOutEndpointURL,
Logger: logger,
ScopesSupported: config.Config.AllowedScopes,
}
identifierIdentityManager := managers.NewIdentifierIdentityManager(identityManagerConfig, activeIdentifier)
logger.Infoln("using identifier backed identity manager")
return identifierIdentityManager, nil
}
+554
View File
@@ -0,0 +1,554 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package bootstrap
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"fmt"
"io/ioutil"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/longsleep/rndm"
"github.com/sirupsen/logrus"
"github.com/libregraph/lico/config"
"github.com/libregraph/lico/encryption"
"github.com/libregraph/lico/identity"
"github.com/libregraph/lico/managers"
oidcProvider "github.com/libregraph/lico/oidc/provider"
"github.com/libregraph/lico/utils"
)
// API types.
type APIType string
const (
APITypeKonnect APIType = "konnect"
APITypeSignin APIType = "signin"
)
// Defaults.
const (
DefaultSigningKeyID = "default"
DefaultSigningKeyBits = 2048
DefaultGuestIdentityManagerName = "guest"
DefaultCookieSameSite = http.SameSiteNoneMode
)
// Bootstrap is a data structure to hold configuration required to start
// konnectd.
type Bootstrap interface {
Config() *Config
Managers() *managers.Managers
MakeURIPath(api APIType, subpath string) string
}
// Implementation of the bootstrap interface.
type bootstrap struct {
config *Config
uriBasePath string
managers *managers.Managers
}
// Config returns the bootstap configuration.
func (bs *bootstrap) Config() *Config {
return bs.config
}
// Managers returns bootstrapped identity-managers.
func (bs *bootstrap) Managers() *managers.Managers {
return bs.managers
}
// Boot is the main entry point to bootstrap the service after validating the
// given configuration. The resulting Bootstrap struct can be used to retrieve
// configured identity-managers and their respective http-handlers and config.
//
// This function should be used by consumers which want to embed this project
// as a library.
func Boot(ctx context.Context, settings *Settings, cfg *config.Config) (Bootstrap, error) {
// NOTE(longsleep): Ensure to use same salt length as the hash size.
// See https://www.ietf.org/mail-archive/web/jose/current/msg02901.html for
// reference and https://github.com/golang-jwt/jwt/v4/issues/285 for
// the issue in upstream jwt-go.
for _, alg := range []string{jwt.SigningMethodPS256.Name, jwt.SigningMethodPS384.Name, jwt.SigningMethodPS512.Name} {
sm := jwt.GetSigningMethod(alg)
if signingMethodRSAPSS, ok := sm.(*jwt.SigningMethodRSAPSS); ok {
signingMethodRSAPSS.Options.SaltLength = rsa.PSSSaltLengthEqualsHash
}
}
bs := &bootstrap{
config: &Config{
Config: cfg,
Settings: settings,
},
}
err := bs.initialize(settings)
if err != nil {
return nil, err
}
err = bs.setup(ctx, settings)
if err != nil {
return nil, err
}
return bs, nil
}
// initialize, parsed parameters from commandline with validation and adds them
// to the associated Bootstrap data.
func (bs *bootstrap) initialize(settings *Settings) error {
logger := bs.config.Config.Logger
var err error
if settings.IdentityManager == "" {
return fmt.Errorf("identity-manager argument missing, use one of kc, ldap, cookie, dummy")
}
bs.config.IssuerIdentifierURI, err = url.Parse(settings.Iss)
if err != nil {
return fmt.Errorf("invalid iss value, iss is not a valid URL), %v", err)
} else if settings.Iss == "" {
return fmt.Errorf("missing iss value, did you provide the --iss parameter?")
} else if bs.config.IssuerIdentifierURI.Scheme != "https" {
return fmt.Errorf("invalid iss value, URL must start with https://")
} else if bs.config.IssuerIdentifierURI.Host == "" {
return fmt.Errorf("invalid iss value, URL must have a host")
}
bs.uriBasePath = settings.URIBasePath
bs.config.SignInFormURI, err = url.Parse(settings.SignInURI)
if err != nil {
return fmt.Errorf("invalid sign-in URI, %v", err)
}
bs.config.SignedOutURI, err = url.Parse(settings.SignedOutURI)
if err != nil {
return fmt.Errorf("invalid signed-out URI, %v", err)
}
bs.config.AuthorizationEndpointURI, err = url.Parse(settings.AuthorizationEndpointURI)
if err != nil {
return fmt.Errorf("invalid authorization-endpoint-uri, %v", err)
}
bs.config.EndSessionEndpointURI, err = url.Parse(settings.EndsessionEndpointURI)
if err != nil {
return fmt.Errorf("invalid endsession-endpoint-uri, %v", err)
}
if settings.Insecure {
// NOTE(longsleep): This disable http2 client support. See https://github.com/golang/go/issues/14275 for reasons.
bs.config.TLSClientConfig = utils.InsecureSkipVerifyTLSConfig()
logger.Warnln("insecure mode, TLS client connections are susceptible to man-in-the-middle attacks")
} else {
bs.config.TLSClientConfig = utils.DefaultTLSConfig()
}
for _, trustedProxy := range settings.TrustedProxy {
if ip := net.ParseIP(trustedProxy); ip != nil {
bs.config.Config.TrustedProxyIPs = append(bs.config.Config.TrustedProxyIPs, &ip)
continue
}
if _, ipNet, errParseCIDR := net.ParseCIDR(trustedProxy); errParseCIDR == nil {
bs.config.Config.TrustedProxyNets = append(bs.config.Config.TrustedProxyNets, ipNet)
continue
}
}
if len(bs.config.Config.TrustedProxyIPs) > 0 {
logger.Infoln("trusted proxy IPs", bs.config.Config.TrustedProxyIPs)
}
if len(bs.config.Config.TrustedProxyNets) > 0 {
logger.Infoln("trusted proxy networks", bs.config.Config.TrustedProxyNets)
}
if len(settings.AllowScope) > 0 {
bs.config.Config.AllowedScopes = settings.AllowScope
logger.Infoln("using custom allowed OAuth 2 scopes", bs.config.Config.AllowedScopes)
}
bs.config.Config.AllowClientGuests = settings.AllowClientGuests
if bs.config.Config.AllowClientGuests {
logger.Infoln("client controlled guests are enabled")
}
bs.config.Config.AllowDynamicClientRegistration = settings.AllowDynamicClientRegistration
if bs.config.Config.AllowDynamicClientRegistration {
logger.Infoln("dynamic client registration is enabled")
}
encryptionSecretFn := settings.EncryptionSecretFile
if encryptionSecretFn != "" {
logger.WithField("file", encryptionSecretFn).Infoln("loading encryption secret from file")
bs.config.EncryptionSecret, err = ioutil.ReadFile(encryptionSecretFn)
if err != nil {
return fmt.Errorf("failed to load encryption secret from file: %v", err)
}
if len(bs.config.EncryptionSecret) != encryption.KeySize {
return fmt.Errorf("invalid encryption secret size - must be %d bytes", encryption.KeySize)
}
} else {
logger.Warnf("missing --encryption-secret parameter, using random encyption secret with %d bytes", encryption.KeySize)
bs.config.EncryptionSecret = rndm.GenerateRandomBytes(encryption.KeySize)
}
bs.config.Config.ListenAddr = settings.Listen
bs.config.IdentifierClientDisabled = settings.IdentifierClientDisabled
bs.config.IdentifierClientPath = settings.IdentifierClientPath
bs.config.IdentifierRegistrationConf = settings.IdentifierRegistrationConf
if bs.config.IdentifierRegistrationConf != "" {
bs.config.IdentifierRegistrationConf, _ = filepath.Abs(bs.config.IdentifierRegistrationConf)
if _, errStat := os.Stat(bs.config.IdentifierRegistrationConf); errStat != nil {
return fmt.Errorf("identifier-registration-conf file not found or unable to access: %v", errStat)
}
bs.config.IdentifierAuthoritiesConf = bs.config.IdentifierRegistrationConf
}
bs.config.IdentifierScopesConf = settings.IdentifierScopesConf
if bs.config.IdentifierScopesConf != "" {
bs.config.IdentifierScopesConf, _ = filepath.Abs(bs.config.IdentifierScopesConf)
if _, errStat := os.Stat(bs.config.IdentifierScopesConf); errStat != nil {
return fmt.Errorf("identifier-scopes-conf file not found or unable to access: %v", errStat)
}
}
if settings.IdentifierDefaultBannerLogo != "" {
// Load from file.
b, errRead := ioutil.ReadFile(settings.IdentifierDefaultBannerLogo)
if errRead != nil {
return fmt.Errorf("identifier-default-banner-logo failed to open: %w", errRead)
}
bs.config.IdentifierDefaultBannerLogo = b
}
if settings.IdentifierDefaultSignInPageText != "" {
bs.config.IdentifierDefaultSignInPageText = &settings.IdentifierDefaultSignInPageText
}
if settings.IdentifierDefaultLogoTargetURI != "" {
bs.config.IdentifierDefaultLogoTargetURI = &settings.IdentifierDefaultLogoTargetURI
}
if settings.IdentifierDefaultUsernameHintText != "" {
bs.config.IdentifierDefaultUsernameHintText = &settings.IdentifierDefaultUsernameHintText
}
bs.config.IdentifierUILocales = settings.IdentifierUILocales
bs.config.SigningKeyID = settings.SigningKid
bs.config.Signers = make(map[string]crypto.Signer)
bs.config.Validators = make(map[string]crypto.PublicKey)
bs.config.Certificates = make(map[string][]*x509.Certificate)
signingMethodString := settings.SigningMethod
bs.config.SigningMethod = jwt.GetSigningMethod(signingMethodString)
if bs.config.SigningMethod == nil {
return fmt.Errorf("unknown signing method: %s", signingMethodString)
}
signingKeyFns := settings.SigningPrivateKeyFiles
if len(signingKeyFns) > 0 {
first := true
for _, signingKeyFn := range signingKeyFns {
logger.WithField("path", signingKeyFn).Infoln("loading signing key")
err = addSignerWithIDFromFile(signingKeyFn, "", bs)
if err != nil {
return err
}
if first {
// Also add key under the provided id.
first = false
err = addSignerWithIDFromFile(signingKeyFn, bs.config.SigningKeyID, bs)
if err != nil {
return err
}
}
}
} else {
//NOTE(longsleep): remove me - create keypair a random key pair.
sm := jwt.SigningMethodPS256
bs.config.SigningMethod = sm
logger.WithField("alg", sm.Name).Warnf("missing --signing-private-key parameter, using random %d bit signing key", DefaultSigningKeyBits)
signer, _ := rsa.GenerateKey(rand.Reader, DefaultSigningKeyBits)
bs.config.Signers[bs.config.SigningKeyID] = signer
}
// Ensure we have a signer for the things we need.
err = validateSigners(bs)
if err != nil {
return err
}
validationKeysPath := settings.ValidationKeysPath
if validationKeysPath != "" {
logger.WithField("path", validationKeysPath).Infoln("loading validation keys")
err = addValidatorsFromPath(validationKeysPath, bs)
if err != nil {
return err
}
}
bs.config.Config.HTTPTransport = utils.HTTPTransportWithTLSClientConfig(bs.config.TLSClientConfig)
bs.config.AccessTokenDurationSeconds = settings.AccessTokenDurationSeconds
if bs.config.AccessTokenDurationSeconds == 0 {
bs.config.AccessTokenDurationSeconds = 60 * 10 // 10 Minutes
}
bs.config.IDTokenDurationSeconds = settings.IDTokenDurationSeconds
if bs.config.IDTokenDurationSeconds == 0 {
bs.config.IDTokenDurationSeconds = 60 * 60 // 1 Hour
}
bs.config.RefreshTokenDurationSeconds = settings.RefreshTokenDurationSeconds
if bs.config.RefreshTokenDurationSeconds == 0 {
bs.config.RefreshTokenDurationSeconds = 60 * 60 * 24 * 365 * 3 // 3 Years
}
bs.config.DyamicClientSecretDurationSeconds = settings.DyamicClientSecretDurationSeconds
// add setting to allow setting the same site attribute of the cookies
bs.config.CookieSameSite = settings.CookieSameSite
if bs.config.CookieSameSite == 0 {
bs.config.CookieSameSite = DefaultCookieSameSite
}
return nil
}
// setup takes care of setting up the managers based on the associated
// Bootstrap's data.
func (bs *bootstrap) setup(ctx context.Context, settings *Settings) error {
managers, err := newManagers(ctx, bs)
if err != nil {
return err
}
bs.managers = managers
identityManager, err := bs.setupIdentity(ctx, settings)
if err != nil {
return err
}
managers.Set("identity", identityManager)
guestManager, err := bs.setupGuest(ctx, identityManager)
if err != nil {
return err
}
managers.Set("guest", guestManager)
oidcProvider, err := bs.setupOIDCProvider(ctx)
if err != nil {
return err
}
managers.Set("oidc", oidcProvider)
managers.Set("handler", oidcProvider) // Use OIDC provider as default HTTP handler.
err = managers.Apply()
if err != nil {
return fmt.Errorf("failed to apply managers: %v", err)
}
// Final steps
err = oidcProvider.InitializeMetadata()
if err != nil {
return fmt.Errorf("failed to initialize provider metadata: %v", err)
}
return nil
}
func (bs *bootstrap) MakeURIPath(api APIType, subpath string) string {
subpath = strings.TrimPrefix(subpath, "/")
uriPath := ""
switch api {
case APITypeKonnect:
uriPath = fmt.Sprintf("%s/konnect/v1/%s", strings.TrimSuffix(bs.uriBasePath, "/"), subpath)
case APITypeSignin:
uriPath = fmt.Sprintf("%s/signin/v1/%s", strings.TrimSuffix(bs.uriBasePath, "/"), subpath)
default:
panic("unknown api type")
}
if subpath == "" {
uriPath = strings.TrimSuffix(uriPath, "/")
}
return uriPath
}
func (bs *bootstrap) MakeURI(api APIType, subpath string) *url.URL {
uriPath := bs.MakeURIPath(api, subpath)
uri, _ := url.Parse(bs.config.IssuerIdentifierURI.String())
uri.Path = uriPath
return uri
}
func (bs *bootstrap) setupIdentity(ctx context.Context, settings *Settings) (identity.Manager, error) {
logger := bs.config.Config.Logger
if settings.IdentityManager == "" {
return nil, fmt.Errorf("identity-manager argument missing")
}
// Identity manager.
identityManagerName := settings.IdentityManager
identityManager, err := getIdentityManagerByName(identityManagerName, bs)
if err != nil {
return nil, err
}
logger.WithFields(logrus.Fields{
"name": identityManagerName,
"scopes": identityManager.ScopesSupported(nil),
"claims": identityManager.ClaimsSupported(nil),
}).Infoln("identity manager set up")
return identityManager, nil
}
func (bs *bootstrap) setupGuest(ctx context.Context, identityManager identity.Manager) (identity.Manager, error) {
if !bs.config.Config.AllowClientGuests {
return nil, nil
}
var err error
logger := bs.config.Config.Logger
guestManager, err := getIdentityManagerByName(DefaultGuestIdentityManagerName, bs)
if err != nil {
return nil, err
}
if guestManager != nil {
logger.Infoln("identity guest manager set up")
}
return guestManager, nil
}
func (bs *bootstrap) setupOIDCProvider(ctx context.Context) (*oidcProvider.Provider, error) {
var err error
logger := bs.config.Config.Logger
sessionCookiePath, err := getCommonURLPathPrefix(bs.config.AuthorizationEndpointURI.EscapedPath(), bs.config.EndSessionEndpointURI.EscapedPath())
if err != nil {
return nil, fmt.Errorf("failed to find common URL prefix for authorize and endsession: %v", err)
}
var registrationPath = ""
if bs.config.Config.AllowDynamicClientRegistration {
registrationPath = bs.MakeURIPath(APITypeKonnect, "/register")
}
provider, err := oidcProvider.NewProvider(&oidcProvider.Config{
Config: bs.config.Config,
IssuerIdentifier: bs.config.IssuerIdentifierURI.String(),
WellKnownPath: "/.well-known/openid-configuration",
JwksPath: bs.MakeURIPath(APITypeKonnect, "/jwks.json"),
AuthorizationPath: bs.config.AuthorizationEndpointURI.EscapedPath(),
TokenPath: bs.MakeURIPath(APITypeKonnect, "/token"),
UserInfoPath: bs.MakeURIPath(APITypeKonnect, "/userinfo"),
EndSessionPath: bs.config.EndSessionEndpointURI.EscapedPath(),
CheckSessionIframePath: bs.MakeURIPath(APITypeKonnect, "/session/check-session.html"),
RegistrationPath: registrationPath,
BrowserStateCookiePath: bs.MakeURIPath(APITypeKonnect, "/session/"),
BrowserStateCookieName: "__Secure-KKBS", // Kopano-Konnect-Browser-State
BrowserStateCookieSameSite: bs.config.CookieSameSite,
SessionCookiePath: sessionCookiePath,
SessionCookieName: "__Secure-KKCS", // Kopano-Konnect-Client-Session
SessionCookieSameSite: bs.config.CookieSameSite,
AccessTokenDuration: time.Duration(bs.config.AccessTokenDurationSeconds) * time.Second,
IDTokenDuration: time.Duration(bs.config.IDTokenDurationSeconds) * time.Second,
RefreshTokenDuration: time.Duration(bs.config.RefreshTokenDurationSeconds) * time.Second,
})
if err != nil {
return nil, fmt.Errorf("failed to create provider: %v", err)
}
if bs.config.SigningMethod != nil {
err = provider.SetSigningMethod(bs.config.SigningMethod)
if err != nil {
return nil, fmt.Errorf("failed to set provider signing method: %v", err)
}
}
// All add signers.
for id, signer := range bs.config.Signers {
if id == bs.config.SigningKeyID {
err = provider.SetSigningKey(id, signer)
// Always set default key.
if id != DefaultSigningKeyID {
provider.SetValidationKey(DefaultSigningKeyID, signer.Public())
}
} else {
// Set non default signers as well.
err = provider.SetSigningKey(id, signer)
}
if err != nil {
return nil, err
}
}
// Add all validators.
for id, publicKey := range bs.config.Validators {
err = provider.SetValidationKey(id, publicKey)
if err != nil {
return nil, err
}
}
// Add all certificates.
for id, certificate := range bs.config.Certificates {
err = provider.SetCertificate(id, certificate)
if err != nil {
return nil, err
}
}
sk, ok := provider.GetSigningKey(bs.config.SigningMethod)
if !ok {
return nil, fmt.Errorf("no signing key for selected signing method")
}
if bs.config.SigningKeyID == "" {
// Ensure that there is a default signing Key ID even if none was set.
provider.SetValidationKey(DefaultSigningKeyID, sk.PrivateKey.Public())
}
logger.WithFields(logrus.Fields{
"id": sk.ID,
"method": fmt.Sprintf("%T", sk.SigningMethod),
"alg": sk.SigningMethod.Alg(),
}).Infoln("oidc token signing default set up")
return provider, nil
}
+71
View File
@@ -0,0 +1,71 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package bootstrap
import (
"crypto"
"crypto/tls"
"crypto/x509"
"net/http"
"net/url"
"github.com/golang-jwt/jwt/v5"
"github.com/libregraph/lico/config"
)
// Config is a typed application config which represents the active
// bootstrap configuration.
type Config struct {
Config *config.Config
Settings *Settings
SignInFormURI *url.URL
SignedOutURI *url.URL
AuthorizationEndpointURI *url.URL
EndSessionEndpointURI *url.URL
TLSClientConfig *tls.Config
IssuerIdentifierURI *url.URL
IdentifierClientDisabled bool
IdentifierClientPath string
IdentifierRegistrationConf string
IdentifierAuthoritiesConf string
IdentifierScopesConf string
IdentifierDefaultBannerLogo []byte
IdentifierDefaultSignInPageText *string
IdentifierDefaultLogoTargetURI *string
IdentifierDefaultUsernameHintText *string
IdentifierUILocales []string
EncryptionSecret []byte
SigningMethod jwt.SigningMethod
SigningKeyID string
Signers map[string]crypto.Signer
Validators map[string]crypto.PublicKey
Certificates map[string][]*x509.Certificate
AccessTokenDurationSeconds uint64
IDTokenDurationSeconds uint64
RefreshTokenDurationSeconds uint64
DyamicClientSecretDurationSeconds uint64
CookieSameSite http.SameSite
}
+88
View File
@@ -0,0 +1,88 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package bootstrap
import (
"context"
"fmt"
"time"
"github.com/libregraph/lico/identity"
identityAuthorities "github.com/libregraph/lico/identity/authorities"
identityClients "github.com/libregraph/lico/identity/clients"
identityManagers "github.com/libregraph/lico/identity/managers"
"github.com/libregraph/lico/managers"
codeManagers "github.com/libregraph/lico/oidc/code/managers"
)
type IdentityManagerFactory func(Bootstrap) (identity.Manager, error)
var identityManagerRegistry = make(map[string]IdentityManagerFactory)
func RegisterIdentityManager(name string, f IdentityManagerFactory) error {
identityManagerRegistry[name] = f
return nil
}
func getIdentityManagerByName(name string, bs Bootstrap) (identity.Manager, error) {
if f, found := identityManagerRegistry[name]; !found {
return nil, fmt.Errorf("no identity manager with name %s registered", name)
} else {
return f(bs)
}
}
func newManagers(ctx context.Context, bs *bootstrap) (*managers.Managers, error) {
logger := bs.config.Config.Logger
var err error
mgrs := managers.New()
// Encryption manager.
encryption, err := identityManagers.NewEncryptionManager(nil)
if err != nil {
return nil, fmt.Errorf("failed to create encryption manager: %v", err)
}
err = encryption.SetKey(bs.config.EncryptionSecret)
if err != nil {
return nil, fmt.Errorf("invalid --encryption-secret parameter value for encryption: %v", err)
}
mgrs.Set("encryption", encryption)
logger.Infof("encryption set up with %d key size", encryption.GetKeySize())
// OIDC code manage.
code := codeManagers.NewMemoryMapManager(ctx)
mgrs.Set("code", code)
// Identifier client registry manager.
clients, err := identityClients.NewRegistry(ctx, bs.config.IssuerIdentifierURI, bs.config.IdentifierRegistrationConf, bs.config.Config.AllowDynamicClientRegistration, time.Duration(bs.config.DyamicClientSecretDurationSeconds)*time.Second, logger)
if err != nil {
return nil, fmt.Errorf("failed to create client registry: %v", err)
}
mgrs.Set("clients", clients)
// Identifier authorities registry manager.
authorities, err := identityAuthorities.NewRegistry(ctx, bs.MakeURI(APITypeSignin, ""), bs.config.IdentifierAuthoritiesConf, logger)
if err != nil {
return nil, fmt.Errorf("failed to create authorities registry: %v", err)
}
mgrs.Set("authorities", authorities)
return mgrs, nil
}
+61
View File
@@ -0,0 +1,61 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package bootstrap
import (
"net/http"
)
// Settings is a typed application config which represents the user accessible
// boostrap settings params.
type Settings struct {
Iss string
IdentityManager string
URIBasePath string
SignInURI string
SignedOutURI string
AuthorizationEndpointURI string
EndsessionEndpointURI string
Insecure bool
TrustedProxy []string
AllowScope []string
AllowClientGuests bool
AllowDynamicClientRegistration bool
EncryptionSecretFile string
Listen string
IdentifierClientDisabled bool
IdentifierClientPath string
IdentifierRegistrationConf string
IdentifierScopesConf string
IdentifierDefaultBannerLogo string
IdentifierDefaultSignInPageText string
IdentifierDefaultLogoTargetURI string
IdentifierDefaultUsernameHintText string
IdentifierUILocales []string
SigningKid string
SigningMethod string
SigningPrivateKeyFiles []string
ValidationKeysPath string
CookieBackendURI string
CookieNames []string
CookieSameSite http.SameSite
AccessTokenDurationSeconds uint64
IDTokenDurationSeconds uint64
RefreshTokenDurationSeconds uint64
DyamicClientSecretDurationSeconds uint64
}
+408
View File
@@ -0,0 +1,408 @@
package bootstrap
import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"io/ioutil"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/go-jose/go-jose/v3"
"github.com/golang-jwt/jwt/v5"
"github.com/sirupsen/logrus"
)
func parseJSONWebKey(jsonBytes []byte) (*jose.JSONWebKey, error) {
k := &jose.JSONWebKey{}
if err := k.UnmarshalJSON(jsonBytes); err != nil {
return nil, err
}
return k, nil
}
// LoadSignerFromFile loads a private-key for signing
//
// Supports JSON (JWK/JWS) and PEM
func LoadSignerFromFile(fn string) (string, crypto.Signer, error) {
readBytes, errRead := ioutil.ReadFile(fn)
if errRead != nil {
return "", nil, fmt.Errorf("failed to parse key file: %v", errRead)
}
ext := filepath.Ext(fn)
switch ext {
case ".json":
k, err := parseJSONWebKey(readBytes)
if err != nil {
return "", nil, fmt.Errorf("failed to parse key file as JWK: %v", err)
}
if !k.Valid() {
return "", nil, fmt.Errorf("json file is not a valid JWK")
}
if k.IsPublic() {
return "", nil, fmt.Errorf("JWK is a public key, private key required to use as signer")
}
signer, ok := k.Key.(crypto.Signer)
if !ok {
return "", nil, fmt.Errorf("JWS key type %T is not a signer", k.Key)
}
return k.KeyID, signer, nil
case ".pem":
fallthrough
default:
// Try PEM if not otherwise detected.
signer, err := parsePEMSigner(readBytes)
return "", signer, err
}
}
func parsePEMSigner(pemBytes []byte) (crypto.Signer, error) {
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, fmt.Errorf("no PEM block found")
}
var signer crypto.Signer
for {
pkcs1Key, errParse1 := x509.ParsePKCS1PrivateKey(block.Bytes)
if errParse1 == nil {
signer = pkcs1Key
break
}
pkcs8Key, errParse2 := x509.ParsePKCS8PrivateKey(block.Bytes)
if errParse2 == nil {
signerSigner, ok := pkcs8Key.(crypto.Signer)
if !ok {
return nil, fmt.Errorf("failed to use key as crypto signer")
}
signer = signerSigner
break
}
ecKey, errParse3 := x509.ParseECPrivateKey(block.Bytes)
if errParse3 == nil {
signer = ecKey
break
}
return nil, fmt.Errorf("failed to parse signer key - valid PKCS#1, PKCS#8 ...? %v, %v, %v", errParse1, errParse2, errParse3)
}
return signer, nil
}
// LoadValidatorFromFile loads a public-key used for validation.
//
// Supported formats are JSON-JWK and PEM
func LoadValidatorFromFile(fn string) (string, crypto.PublicKey, error) {
kid, _, key, err := loadValidatorFromFile(fn)
return kid, key, err
}
// LoadCertificatesAndValidatorFromFile loads chain of certificates and a
// public-key used for validation.
//
// Supported formats are JSON-JWK and PEM
func LoadCertificatesAndValidatorFromFile(fn string) (string, []*x509.Certificate, crypto.PublicKey, error) {
return loadValidatorFromFile(fn)
}
func loadValidatorFromFile(fn string) (string, []*x509.Certificate, crypto.PublicKey, error) {
readBytes, errRead := ioutil.ReadFile(fn)
if errRead != nil {
return "", nil, nil, fmt.Errorf("failed to parse key file: %v", errRead)
}
ext := filepath.Ext(fn)
switch ext {
case ".json":
k, err := parseJSONWebKey(readBytes)
if err != nil {
return "", nil, nil, fmt.Errorf("failed to parse key file as JWK: %v", err)
}
if !k.Valid() {
return "", nil, nil, fmt.Errorf("json file is not a valid JWK")
}
if !k.IsPublic() {
public := k.Public()
k = &public
}
return k.KeyID, k.Certificates, k.Key, nil
case ".pem":
fallthrough
default:
// Try PEM if not otherwise detected.
certificates, validator, err := parsePEMValidator(readBytes)
return "", certificates, validator, err
}
}
func parsePEMValidator(pemBytes []byte) ([]*x509.Certificate, crypto.PublicKey, error) {
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, nil, fmt.Errorf("no PEM block found")
}
var certificates []*x509.Certificate
var validator crypto.PublicKey
for {
pkixPubKey, errParse0 := x509.ParsePKIXPublicKey(block.Bytes)
if errParse0 == nil {
validator = pkixPubKey
break
}
pkcs1PubKey, errParse1 := x509.ParsePKCS1PublicKey(block.Bytes)
if errParse1 == nil {
validator = pkcs1PubKey
break
}
pkcs1PrivKey, errParse2 := x509.ParsePKCS1PrivateKey(block.Bytes)
if errParse2 == nil {
validator = pkcs1PrivKey.Public()
break
}
pkcs8Key, errParse3 := x509.ParsePKCS8PrivateKey(block.Bytes)
if errParse3 == nil {
signerSigner, ok := pkcs8Key.(crypto.Signer)
if !ok {
return nil, nil, fmt.Errorf("failed to use key as crypto signer")
}
validator = signerSigner.Public()
break
}
ecKey, errParse4 := x509.ParseECPrivateKey(block.Bytes)
if errParse4 == nil {
validator = ecKey.Public()
break
}
certs, errParse5 := x509.ParseCertificates(block.Bytes)
if errParse5 == nil {
validator = certs[0].PublicKey
certificates = append(certificates, certs...)
break
}
return nil, nil, fmt.Errorf("failed to parse validator key - valid PKCS#1, PKCS#8 ...? %v, %v, %v, %v, %v, %v", errParse0, errParse1, errParse2, errParse3, errParse4, errParse5)
}
return certificates, validator, nil
}
func addSignerWithIDFromFile(fn string, kid string, bs *bootstrap) error {
fi, err := os.Lstat(fn)
if err != nil {
return fmt.Errorf("failed load load signer key: %v", err)
}
mode := fi.Mode()
switch {
case mode.IsDir():
return fmt.Errorf("signer key must be a file")
}
// Load file.
signerKid, signer, err := LoadSignerFromFile(fn)
if err != nil {
return err
}
if kid == "" {
kid = signerKid
}
if kid == "" {
// Get ID from file, following symbolic link.
var real string
if mode&os.ModeSymlink != 0 {
real, err = os.Readlink(fn)
if err != nil {
return err
}
_, real = filepath.Split(real)
} else {
real = fi.Name()
}
kid = getKeyIDFromFilename(real)
}
if _, ok := bs.config.Signers[kid]; ok {
bs.config.Config.Logger.WithFields(logrus.Fields{
"path": fn,
"kid": kid,
}).Warnln("skipped as signer with same kid already loaded")
return nil
} else {
bs.config.Config.Logger.WithFields(logrus.Fields{
"path": fn,
"kid": kid,
}).Debugln("loaded signer key")
}
bs.config.Signers[kid] = signer
return nil
}
func validateSigners(bs *bootstrap) error {
haveRSA := false
haveECDSA := false
haveEd25519 := false
for _, signer := range bs.config.Signers {
switch s := signer.(type) {
case *rsa.PrivateKey:
// Ensure the private key is not vulnerable with PKCS-1.5 signatures. See
// https://paragonie.com/blog/2018/04/protecting-rsa-based-protocols-against-adaptive-chosen-ciphertext-attacks#rsa-anti-bb98
// for details.
if s.PublicKey.E < 65537 {
return fmt.Errorf("RSA signing key with public exponent < 65537")
}
haveRSA = true
case *ecdsa.PrivateKey:
haveECDSA = true
case ed25519.PrivateKey:
haveEd25519 = true
default:
return fmt.Errorf("unsupported signer type: %v", s)
}
}
// Validate signing method
switch bs.config.SigningMethod.(type) {
case *jwt.SigningMethodRSA:
if !haveRSA {
return fmt.Errorf("no private key for signing method: %s", bs.config.SigningMethod.Alg())
}
case *jwt.SigningMethodRSAPSS:
if !haveRSA {
return fmt.Errorf("no private key for signing method: %s", bs.config.SigningMethod.Alg())
}
case *jwt.SigningMethodECDSA:
if !haveECDSA {
return fmt.Errorf("no private key for signing method: %s", bs.config.SigningMethod.Alg())
}
case *jwt.SigningMethodEd25519:
if !haveEd25519 {
return fmt.Errorf("no private key for signing method: %s", bs.config.SigningMethod.Alg())
}
default:
return fmt.Errorf("unsupported signing method: %s", bs.config.SigningMethod.Alg())
}
if !haveRSA {
bs.config.Config.Logger.Warnln("no RSA signing private key, some clients might not be compatible")
}
return nil
}
func addValidatorsFromPath(pn string, bs *bootstrap) error {
fi, err := os.Lstat(pn)
if err != nil {
return fmt.Errorf("failed load load validator keys: %v", err)
}
switch mode := fi.Mode(); {
case mode.IsDir():
// OK.
default:
return fmt.Errorf("validator path must be a directory")
}
// Load all files.
files := []string{}
if pemFiles, err := filepath.Glob(filepath.Join(pn, "*.pem")); err != nil {
return fmt.Errorf("validator path err: %v", err)
} else {
files = append(files, pemFiles...)
}
if jsonFiles, err := filepath.Glob(filepath.Join(pn, "*.json")); err != nil {
return fmt.Errorf("validator path err: %v", err)
} else {
files = append(files, jsonFiles...)
}
for _, file := range files {
kid, certificates, validator, err := loadValidatorFromFile(file)
if err != nil {
bs.config.Config.Logger.WithError(err).WithField("path", file).Warnln("failed to load validator key")
continue
}
// Get ID from file, without following symbolic links.
if kid == "" {
_, fn := filepath.Split(file)
kid = getKeyIDFromFilename(fn)
}
if _, ok := bs.config.Validators[kid]; ok {
bs.config.Config.Logger.WithFields(logrus.Fields{
"path": file,
"kid": kid,
}).Warnln("skipped as validator with same kid already loaded")
continue
} else {
bs.config.Config.Logger.WithFields(logrus.Fields{
"path": file,
"kid": kid,
}).Debugln("loaded validator key")
}
bs.config.Validators[kid] = validator
if certificates != nil {
bs.config.Certificates[kid] = certificates
}
}
return nil
}
func WithSchemeAndHost(u, base *url.URL) *url.URL {
if u.Host != "" && u.Scheme != "" {
return u
}
r, _ := url.Parse(u.String())
r.Scheme = base.Scheme
r.Host = base.Host
return r
}
func getKeyIDFromFilename(fn string) string {
ext := filepath.Ext(fn)
return strings.TrimSuffix(fn, ext)
}
func getCommonURLPathPrefix(p1, p2 string) (string, error) {
parts1 := strings.Split(p1, "/")
parts2 := strings.Split(p2, "/")
common := make([]string, 0)
for idx, p := range parts1 {
if idx >= len(parts2) {
break
}
if p != parts2[idx] {
break
}
common = append(common, p)
}
if len(common) == 0 {
return "", errors.New("no common path prefix")
}
return strings.Join(common, "/"), nil
}
+162
View File
@@ -0,0 +1,162 @@
/*
* Copyright 2017-2021 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package lico
import (
"errors"
"github.com/golang-jwt/jwt/v5"
"github.com/libregraph/lico/oidc"
"github.com/libregraph/lico/oidc/payload"
)
// Access token claims used.
const (
RefClaim = "lg.r"
IdentityClaim = "lg.i"
IdentityProviderClaim = "lg.p"
ScopesClaim = "scp"
)
// Identifier identity sub claims used.
const (
IdentifiedUserClaim = "us"
IdentifiedUserIDClaim = "id"
IdentifiedUsernameClaim = "un"
IdentifiedDisplayNameClaim = "dn"
IdentifiedData = "da"
IdentifiedUserIsGuest = "gu"
)
// Internal claim names used for special things.
const (
InternalExtraIDTokenClaimsClaim = "$lico.id.extra"
InternalExtraAccessTokenClaimsClaim = "$lico.at.extra"
)
// TokenType defines the token type value.
type TokenTypeValue string
// Is compares the associated TokenTypeValue to the provided one.
func (ttv TokenTypeValue) Is(value TokenTypeValue) bool {
return ttv == value
}
// The known token type values.
const (
TokenTypeIDToken TokenTypeValue = "" // Just a placeholder, not actually set in ID Tokens.
TokenTypeAccessToken TokenTypeValue = "1"
TokenTypeRefreshToken TokenTypeValue = "2"
)
// AccessTokenClaims define the claims found in access tokens issued.
type AccessTokenClaims struct {
jwt.RegisteredClaims
TokenType TokenTypeValue `json:"lg.t"`
AuthorizedClaimsRequest *payload.ClaimsRequest `json:"lg.acr,omitempty"`
AuthorizedScopesList payload.ScopesValue `json:"scp"`
IdentityClaims jwt.MapClaims `json:"lg.i"`
IdentityProvider string `json:"lg.p,omitempty"`
*oidc.SessionClaims
}
// Validate implements the jwt.ClaimsValidator interface.
func (c AccessTokenClaims) Validate() error {
if !c.TokenType.Is(TokenTypeAccessToken) {
return errors.New("not an access token")
}
if len(c.Audience) != 1 {
return errors.New("access token must have exactly one audience value")
}
return nil
}
// AuthorizedScopes returns a map with scope keys and true value of all scopes
// set in the accociated access token.
func (c AccessTokenClaims) AuthorizedScopes() map[string]bool {
authorizedScopes := make(map[string]bool)
for _, scope := range c.AuthorizedScopesList {
authorizedScopes[scope] = true
}
return authorizedScopes
}
// RefreshTokenClaims define the claims used by refresh tokens.
type RefreshTokenClaims struct {
jwt.RegisteredClaims
TokenType TokenTypeValue `json:"lg.t"`
ApprovedScopesList payload.ScopesValue `json:"scp"`
ApprovedClaimsRequest *payload.ClaimsRequest `json:"lg.acr,omitempty"`
Ref string `json:"lg.r"`
IdentityClaims jwt.MapClaims `json:"lg.i"`
IdentityProvider string `json:"lg.p,omitempty"`
}
// Validate implements the jwt.ClaimsValidator interface.
func (c RefreshTokenClaims) Validate() error {
if !c.TokenType.Is(TokenTypeRefreshToken) {
return errors.New("not a refresh token")
}
if len(c.Audience) != 1 {
return errors.New("refresh token must have exactly one audience value")
}
return nil
}
// NumericIDClaims define the claims used with the konnect/id scope.
type NumericIDClaims struct {
// NOTE(longsleep): Always keep these claims compatible with the GitLab API
// https://docs.gitlab.com/ce/api/users.html#for-user.
NumericID int64 `json:"id,omitempty"`
NumericIDUsername string `json:"username,omitempty"`
}
// Validate implements the jwt.ClaimsValidator interface.
func (c NumericIDClaims) Validate() error {
if c.NumericIDUsername == "" {
return errors.New("username claim not valid")
}
return nil
}
// UniqueUserIDClaims define the claims used with the konnect/uuid scope.
type UniqueUserIDClaims struct {
UniqueUserID string `json:"lg.uuid,omitempty"`
}
// Validate implements the jwt.ClaimsValidator interface.
func (c UniqueUserIDClaims) Validate() error {
if c.UniqueUserID == "" {
return errors.New("lg.uuid claim not valid")
}
return nil
}
+42
View File
@@ -0,0 +1,42 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package config
import (
"net"
"net/http"
"github.com/sirupsen/logrus"
)
// Config defines a Server's configuration settings.
type Config struct {
ListenAddr string
WithMetrics bool
Logger logrus.FieldLogger
HTTPTransport http.RoundTripper
TrustedProxyIPs []*net.IP
TrustedProxyNets []*net.IPNet
AllowedScopes []string
AllowClientGuests bool
AllowDynamicClientRegistration bool
}
+59
View File
@@ -0,0 +1,59 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package lico
import (
"context"
"net/http"
"github.com/golang-jwt/jwt/v5"
)
// key is an unexported type for keys defined in this package.
// This prevents collisions with keys defined in other packages.
type key int
// claimsKey is the key for claims in contexts. It is
// unexported; clients use konnect.NewClaimsContext and
// connect.FromClaimsContext instead of using this key directly.
const (
claimsKey key = iota
requestKey
)
// NewClaimsContext returns a new Context that carries value auth.
func NewClaimsContext(ctx context.Context, claims jwt.Claims) context.Context {
return context.WithValue(ctx, claimsKey, claims)
}
// FromClaimsContext returns the AuthRecord value stored in ctx, if any.
func FromClaimsContext(ctx context.Context) (jwt.Claims, bool) {
claims, ok := ctx.Value(claimsKey).(jwt.Claims)
return claims, ok
}
// NewRequestContext returns a new Context that carries a request object.
func NewRequestContext(ctx context.Context, req *http.Request) context.Context {
return context.WithValue(ctx, requestKey, req)
}
// FromRequestContext returns the Request object stored in ctx, if any.
func FromRequestContext(ctx context.Context) (*http.Request, bool) {
req, ok := ctx.Value(requestKey).(*http.Request)
return req, ok
}
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
// Package lico is a Go implementation of an OpenID Connect server with
// flexibale authorization and authentication backends and consent screen.
//
// See README.md for more info.
package lico // import "github.com/libregraph/lico"
+64
View File
@@ -0,0 +1,64 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package encryption
import (
"fmt"
"golang.org/x/crypto/nacl/secretbox"
)
const (
// KeySize is the size of the keys created by GenerateKey()
KeySize = 32
// NonceSize is the size of the nonces created by GenerateNonce()
NonceSize = 24
)
// Encrypt generates a random nonce and encrypts the input using nacl.secretbox
// package. We store the nonce in the first 24 bytes of the encrypted text.
func Encrypt(msg []byte, key *[KeySize]byte) ([]byte, error) {
nonce, err := GenerateNonce()
if err != nil {
return nil, err
}
return encryptWithNonce(msg, nonce, key)
}
func encryptWithNonce(msg []byte, nonce *[NonceSize]byte, key *[KeySize]byte) ([]byte, error) {
encrypted := secretbox.Seal(nonce[:], msg, nonce, key)
return encrypted, nil
}
// Decrypt extracts the nonce from the encrypted text, and attempts to decrypt
// with nacl.box.
func Decrypt(msg []byte, key *[KeySize]byte) ([]byte, error) {
if len(msg) < (NonceSize + secretbox.Overhead) {
return nil, fmt.Errorf("wrong length of ciphertext")
}
var nonce [NonceSize]byte
copy(nonce[:], msg[:NonceSize])
decrypted, ok := secretbox.Open(nil, msg[NonceSize:], &nonce, key)
if !ok {
return nil, fmt.Errorf("decryption failed")
}
return decrypted, nil
}
+45
View File
@@ -0,0 +1,45 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package encryption
import (
"crypto/rand"
"io"
)
// GenerateKey generates a new random secret key.
func GenerateKey() (*[KeySize]byte, error) {
key := new([KeySize]byte)
_, err := io.ReadFull(rand.Reader, key[:])
if err != nil {
return nil, err
}
return key, nil
}
// GenerateNonce creates a new random nonce.
func GenerateNonce() (*[NonceSize]byte, error) {
nonce := new([NonceSize]byte)
_, err := io.ReadFull(rand.Reader, nonce[:])
if err != nil {
return nil, err
}
return nonce, nil
}
@@ -0,0 +1,96 @@
---
# OpenID Connect client registry.
clients:
# - id: playground.js
# name: OIDC Playground
# application_type: web
# redirect_uris:
# - https://my-host:8509/
# origins:
# - https://my-host:8509
# - id: playground-trusted.js
# name: Trusted OIDC Playground
# trusted: yes
# implicit_scopes:
# - Implicitly.Added
# application_type: web
# redirect_uris:
# - https://my-host:8509/
# origins:
# - https://my-host:8509
# - id: playground-trusted.js
# name: Trusted Insecure OIDC Playground
# trusted: yes
# application_type: web
# insecure: yes
# - id: client-with-keys
# secret: super
# application_type: native
# redirect_uris:
# - http://localhost
# trusted_scopes:
# - LibreGraph.GuestOK
# - LibgreGraph.NumericID
# jwks:
# keys:
# - kty: EC
# use: sig
# kid: client-with-keys-key-1
# crv: P-256
# x: RTZpWoRbjwX1YavmSHVBj6Cy3Yzdkkp6QLvTGB22D0c
# y: jeavjwcX0xlDSchFcBMzXSU7wGs2VPpNxWCwmxFvmF0
# request_object_signing_alg: ES256
# - id: first
# secret: lala
# application_type: native
# redirect_uris:
# - my://app
# - id: second
# secret: lulu
# application_type: native
# redirect_uris:
# - http://localhost
# External authority registry.
authorities:
# - id: my-univention-oidc
# name: Univention
# client_id: libregraph-lico
# authority_type: oidc
# jwks:
# keys:
# - kty: EC
# use: sig
# kid: example-key-1
# crv: P-256
# x: RTZpWoRbjwX1YavmSHVBj6Cy3Yzdkkp6QLvTGB22D0c
# y: jeavjwcX0xlDSchFcBMzXSU7wGs2VPpNxWCwmxFvmF0
# default: yes
# authorization_endpoint: https://my-univention/signin/v1/identifier/_/authorize
# response_type: id_token
# scopes:
# - openid
# - profile
# identity_claim_name: preferred_username
# identity_aliases:
# external-user-a: local-user-a
# external-user-b: local-user-b
# identity_alias_required: true
# - id: my-univention-saml2
# name: Univention
# entity_id: libregraph-lico
# authority_type: saml2
# default: yes
# trusted: yes
# discover: yes
# metadata_endpoint: https://my-univention/simplesamlphp/saml2/idp/metadata.php
# identity_claim_name: uid
# identity_alias_required: false
# end_session_enabled: true
+2
View File
@@ -0,0 +1,2 @@
BROWSER=none
VITE_KOPANO_BUILD=0.0.0-dev-env
@@ -0,0 +1,38 @@
# See https://help.github.com/ignore-files/ for more about ignoring files.
# dependencies
/node_modules
/.yarninstall
# testing
/coverage
.eslintcache
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# i18n
src/locales/*.json
src/locales/**/*.json
# yarn
.pnp.*
!.yarnrc.yml
!.yarn/
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
+7
View File
@@ -0,0 +1,7 @@
compressionLevel: mixed
enableGlobalCache: false
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.0.2.cjs
+65
View File
@@ -0,0 +1,65 @@
# Tools
YARN ?= yarn
# Variables
VERSION ?= $(shell git describe --tags --always --dirty --match=v* 2>/dev/null | sed 's/^v//' || \
cat $(CURDIR)/../.version 2> /dev/null || echo 0.0.0-unreleased)
# Build
.PHONY: all
all: build
.PHONY: build
build: vendor | src i18n ; $(info building identifier Webapp ...) @
@rm -rf build
VITE_KOPANO_BUILD="${VERSION}" CI=false $(YARN) run build
.PHONY: src
src:
@$(MAKE) -C src
.PHONY: i18n
i18n: vendor
@$(MAKE) -C i18n
.PHONY: lint
lint: vendor ; $(info running eslint ...) @
@$(YARN) lint . --cache && echo "eslint: no lint errors"
.PHONY: lint-checkstyle
lint-checkstyle: vendor ; $(info running eslint checkstyle ...) @
@mkdir -p ../test
$(YARN) lint -f checkstyle -o ../test/tests.eslint.xml . || true
# Yarn
.PHONY: vendor
vendor: .yarninstall
.yarninstall: package.json ; $(info getting depdencies with yarn ...) @
@$(YARN) install --immutable
@touch $@
# Stuff
.PHONY: licenses
licenses:
echo "## LibreGraph Connect identifier web app\n"
@$(YARN) run licenses
.PHONY: clean ; $(info cleaning identifier Webapp ...) @
clean:
$(YARN) cache clean
@rm -rf build
@rm -rf node_modules
@rm -f .yarninstall
@$(MAKE) -C src clean
.PHONY: version
version:
@echo $(VERSION)
+3
View File
@@ -0,0 +1,3 @@
# LibreGraph Connect Identifier
Web app for browser sign-in, sign-out and account management.
+146
View File
@@ -0,0 +1,146 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package identifier
import (
"bytes"
"fmt"
"net/http"
"github.com/libregraph/oidc-go"
"github.com/longsleep/rndm"
"github.com/libregraph/lico/identifier/meta"
"github.com/libregraph/lico/identifier/meta/scopes"
)
func (i *Identifier) writeWebappIndexHTML(rw http.ResponseWriter, req *http.Request) {
nonce := rndm.GenerateRandomString(32)
// FIXME(longsleep): Set a secure CSP. Right now we need `data:` for images
// since it is used. Since `data:` URLs possibly could allow xss, a better
// way should be found for our early loading inline SVG stuff.
rw.Header().Set("Content-Security-Policy", fmt.Sprintf("default-src 'self'; img-src 'self' data:; font-src 'self' data:; script-src 'self' 'nonce-%s'; style-src 'self' 'nonce-%s'; base-uri 'none'; frame-ancestors 'none';", nonce, nonce))
// Write index with random nonce to response.
index := bytes.ReplaceAll(i.webappIndexHTML, []byte("__CSP_NONCE__"), []byte(nonce))
rw.Write(index)
}
func (i Identifier) writeHelloResponse(rw http.ResponseWriter, req *http.Request, r *HelloRequest, identifiedUser *IdentifiedUser) (*HelloResponse, error) {
var err error
response := &HelloResponse{
State: r.State,
Branding: &meta.Branding{
BannerLogo: i.defaultBannerLogo,
UsernameHintText: i.Config.DefaultUsernameHintText,
SignInPageText: i.Config.DefaultSignInPageText,
SignInPageLogoURI: i.Config.DefaultSignInPageLogoURI,
Locales: i.Config.UILocales,
},
}
handleHelloLoop:
for {
// Check prompt value.
switch {
case r.Prompts[oidc.PromptNone] == true:
// Never show sign-in, directly return error.
return nil, fmt.Errorf("prompt none requested")
case r.Prompts[oidc.PromptLogin] == true:
// Ignore all potential sources, when prompt login was requested.
if identifiedUser != nil {
response.Username = identifiedUser.Username()
response.DisplayName = identifiedUser.Name()
if response.Username != "" {
response.Success = true
}
}
break handleHelloLoop
default:
// Let all other prompt values pass.
}
if identifiedUser == nil {
// Check if logged in via cookie.
identifiedUser, err = i.GetUserFromLogonCookie(req.Context(), req, r.MaxAge, true)
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to decode logon cookie in hello")
}
}
if identifiedUser != nil {
response.Username = identifiedUser.Username()
response.DisplayName = identifiedUser.Name()
if response.Username != "" {
response.Success = true
break
}
}
break
}
if !response.Success {
return response, nil
}
switch r.Flow {
case FlowOAuth:
fallthrough
case FlowConsent:
fallthrough
case FlowOIDC:
// TODO(longsleep): Add something to validate the parameters.
clientDetails, err := i.clients.Lookup(req.Context(), r.ClientID, "", r.RedirectURI, "", true)
if err != nil {
return nil, err
}
promptConsent := false
// Check prompt value.
switch {
case r.Prompts[oidc.PromptConsent] == true:
promptConsent = true
default:
// Let all other prompt values pass.
}
// If not trusted, always force consent.
if !clientDetails.Trusted {
promptConsent = true
}
if promptConsent {
// TODO(longsleep): Filter scopes to scopes we know about and all.
response.Next = FlowConsent
response.Scopes = r.Scopes
response.ClientDetails = clientDetails
response.Meta = &meta.Meta{
Scopes: scopes.NewScopesFromIDs(r.Scopes, i.meta.Scopes),
}
}
// Add authorize endpoint URI as continue URI.
response.ContinueURI = i.authorizationEndpointURI.String()
response.Flow = r.Flow
}
return response, nil
}
@@ -0,0 +1,54 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package backends
import (
"context"
"github.com/libregraph/lico/identifier/meta/scopes"
"github.com/libregraph/lico/identity"
)
// A Backend is an identifier Backend providing functionality to logon and to
// fetch user meta data.
type Backend interface {
RunWithContext(context.Context) error
Logon(ctx context.Context, audience string, username string, password string) (success bool, userID *string, sessionRef *string, user UserFromBackend, err error)
GetUser(ctx context.Context, userID string, sessionRef *string, requestedScopes map[string]bool) (user UserFromBackend, err error)
ResolveUserByUsername(ctx context.Context, username string) (user UserFromBackend, err error)
RefreshSession(ctx context.Context, userID string, sessionRef *string, claims map[string]interface{}) error
DestroySession(ctx context.Context, sessionRef *string) error
UserClaims(userID string, authorizedScopes map[string]bool) map[string]interface{}
ScopesSupported() []string
ScopesMeta() *scopes.Scopes
Name() string
}
// UserFromBackend are users as provided by backends which can have additional
// claims together with a user name.
type UserFromBackend interface {
identity.UserWithUsername
BackendClaims() map[string]interface{}
BackendScopes() []string
RequiredScopes() []string
}
@@ -0,0 +1,41 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package ldap
// Define some known LDAP attribute descriptors.
const (
AttributeDN = "dn"
AttributeLogin = "uid"
AttributeEmail = "mail"
AttributeName = "cn"
AttributeFamilyName = "sn"
AttributeGivenName = "givenName"
AttributeUUID = "uuid"
)
// Additional mappable virtual attributes.
const (
AttributeNumericUID = "konnectNumericID"
)
// Define our known LDAP attribute value types.
const (
AttributeValueTypeText = "text"
AttributeValueTypeBinary = "binary"
AttributeValueTypeUUID = "uuid"
)
@@ -0,0 +1,670 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package ldap
import (
"context"
"crypto/tls"
"encoding/base64"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"time"
"github.com/go-ldap/ldap/v3"
uuid "github.com/gofrs/uuid"
"github.com/libregraph/oidc-go"
"github.com/sirupsen/logrus"
"golang.org/x/time/rate"
konnect "github.com/libregraph/lico"
"github.com/libregraph/lico/config"
"github.com/libregraph/lico/identifier/backends"
"github.com/libregraph/lico/identifier/meta/scopes"
)
const ldapIdentifierBackendName = "identifier-ldap"
var ldapSupportedScopes = []string{
oidc.ScopeProfile,
oidc.ScopeEmail,
konnect.ScopeUniqueUserID,
konnect.ScopeRawSubject,
}
// LDAPIdentifierBackend is a backend for the Identifier which connects LDAP.
type LDAPIdentifierBackend struct {
addr string
isTLS bool
bindDN string
bindPassword string
baseDN string
scope int
searchFilter string
getFilter string
entryIDMapping []string
attributeMapping ldapAttributeMapping
supportedScopes []string
logger logrus.FieldLogger
dialer *net.Dialer
tlsConfig *tls.Config
timeout int
limiter *rate.Limiter
}
type ldapAttributeMapping map[string]string
var ldapDefaultAttributeMapping = ldapAttributeMapping{
AttributeLogin: AttributeLogin,
AttributeEmail: AttributeEmail,
AttributeName: AttributeName,
AttributeFamilyName: AttributeFamilyName,
AttributeGivenName: AttributeGivenName,
AttributeUUID: AttributeUUID,
fmt.Sprintf("%s_type", AttributeUUID): AttributeValueTypeText,
}
func (m ldapAttributeMapping) attributes() []string {
attributes := make([]string, len(m)+1)
attributes[0] = AttributeDN
idx := 1
for _, attribute := range m {
attributes[idx] = attribute
idx++
}
return attributes
}
type ldapUser struct {
entryID string
id int64
data ldapAttributeMapping
}
func newLdapUser(entryID string, mapping ldapAttributeMapping, entry *ldap.Entry) (*ldapUser, error) {
// Go through all returned attributes, add them to the local data set if
// we know them in the mapping.
var id int64
data := make(ldapAttributeMapping)
for _, attribute := range entry.Attributes {
if len(attribute.Values) == 0 {
continue
}
for n, mapped := range mapping {
// LDAP attribute descriptors / short names are case insensitive. See
// https://tools.ietf.org/html/rfc4512#page-4.
if strings.ToLower(attribute.Name) == strings.ToLower(mapped) {
// Check if we need conversion.
switch mapping[fmt.Sprintf("%s_type", n)] {
case AttributeValueTypeBinary:
// Binary gets encoded witih Base64.
data[n] = base64.StdEncoding.EncodeToString(attribute.ByteValues[0])
case AttributeValueTypeUUID:
// Try to decode as UUID https://tools.ietf.org/html/rfc4122 and
// serialize to string.
if value, err := uuid.FromBytes(attribute.ByteValues[0]); err == nil {
data[n] = value.String()
}
default:
data[n] = attribute.Values[0]
}
if n == AttributeNumericUID {
numericID, err := strconv.ParseInt(data[n], 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid numeric ID %v in record", err)
}
id = numericID
}
}
}
}
return &ldapUser{
entryID: entryID,
id: id,
data: data,
}, nil
}
func (u *ldapUser) getAttributeValue(n string) string {
if n == "" {
return ""
}
return u.data[n]
}
func (u *ldapUser) Subject() string {
return u.entryID
}
func (u *ldapUser) Email() string {
return u.getAttributeValue(AttributeEmail)
}
func (u *ldapUser) EmailVerified() bool {
return false
}
func (u *ldapUser) Name() string {
return u.getAttributeValue(AttributeName)
}
func (u *ldapUser) FamilyName() string {
return u.getAttributeValue(AttributeFamilyName)
}
func (u *ldapUser) GivenName() string {
return u.getAttributeValue(AttributeGivenName)
}
func (u *ldapUser) Username() string {
return u.getAttributeValue(AttributeLogin)
}
func (u *ldapUser) ID() int64 {
return u.id
}
func (u *ldapUser) UniqueID() string {
return u.getAttributeValue(AttributeUUID)
}
func (u *ldapUser) BackendClaims() map[string]interface{} {
claims := make(map[string]interface{})
claims[konnect.IdentifiedUserIDClaim] = u.entryID
return claims
}
func (u *ldapUser) BackendScopes() []string {
return nil
}
func (u *ldapUser) RequiredScopes() []string {
return nil
}
// NewLDAPIdentifierBackend creates a new LDAPIdentifierBackend with the provided
// parameters.
func NewLDAPIdentifierBackend(
c *config.Config,
tlsConfig *tls.Config,
uriString,
bindDN,
bindPassword,
baseDN,
scopeString,
filter string,
subAttributes []string,
mappedAttributes map[string]string,
) (*LDAPIdentifierBackend, error) {
var err error
var scope int
var uri *url.URL
for {
if uriString == "" {
err = fmt.Errorf("server must not be empty")
break
}
uri, err = url.Parse(uriString)
if err != nil {
break
}
if bindDN == "" && bindPassword != "" {
err = fmt.Errorf("bind DN must not be empty when bind password is given")
break
}
if baseDN == "" {
err = fmt.Errorf("base DN must not be empty")
break
}
switch scopeString {
case "sub":
scope = ldap.ScopeWholeSubtree
case "one":
scope = ldap.ScopeSingleLevel
case "base":
scope = ldap.ScopeBaseObject
case "":
scope = ldap.ScopeWholeSubtree
default:
err = fmt.Errorf("unknown scope value: %v, must be one of sub, one or base", scopeString)
}
if err != nil {
break
}
break
}
if err != nil {
return nil, fmt.Errorf("ldap identifier backend %v", err)
}
attributeMapping := ldapAttributeMapping{}
for k, v := range ldapDefaultAttributeMapping {
if mapped, ok := mappedAttributes[k]; ok && mapped != "" {
v = mapped
}
attributeMapping[k] = v
c.Logger.WithField("attribute", fmt.Sprintf("%v:%v", k, v)).Debugln("ldap identifier backend set attribute")
}
// Build supported scopes based on default scopes and scope mapping.
supportedScopes := make([]string, len(ldapSupportedScopes))
copy(supportedScopes, ldapSupportedScopes)
if numericUIDAttribute := mappedAttributes[AttributeNumericUID]; numericUIDAttribute != "" {
supportedScopes = append(supportedScopes, konnect.ScopeNumericID)
attributeMapping[AttributeNumericUID] = numericUIDAttribute
c.Logger.WithField("attribute", fmt.Sprintf("%v:%v", AttributeNumericUID, numericUIDAttribute)).Debugln("ldap identifier backend use attribute")
}
if filter == "" {
filter = "(objectClass=inetOrgPerson)"
}
c.Logger.WithField("filter", filter).Debugln("ldap identifier backend set filter")
loginAttribute := attributeMapping[AttributeLogin]
addr := uri.Host
isTLS := false
switch uri.Scheme {
case "":
uri.Scheme = "ldap"
fallthrough
case "ldap":
if uri.Port() == "" {
addr += ":389"
}
case "ldaps":
if uri.Port() == "" {
addr += ":636"
}
// To be able to verify the servers TLS certificate we need to set the
// server's hostname. (Normally tls.DialWithDialer() would take care of
// that, but we're not using that in LDAPIdentifierBackend.connect())
if !tlsConfig.InsecureSkipVerify && tlsConfig.ServerName == "" {
tlsConfig.ServerName = uri.Hostname()
}
isTLS = true
default:
err = fmt.Errorf("invalid URI scheme: %v", uri.Scheme)
}
if err != nil {
return nil, fmt.Errorf("ldap identifier backend %v", err)
}
var entryIDMapping []string
if len(subAttributes) > 0 {
entryIDMapping = subAttributes
c.Logger.WithField("mapping", entryIDMapping).Debugln("ldap identifier sub is mapped")
}
b := &LDAPIdentifierBackend{
addr: addr,
isTLS: isTLS,
bindDN: bindDN,
bindPassword: bindPassword,
baseDN: baseDN,
scope: scope,
searchFilter: fmt.Sprintf("(&(%s)(%s=%%s))", filter, loginAttribute),
getFilter: filter,
entryIDMapping: entryIDMapping,
attributeMapping: attributeMapping,
supportedScopes: supportedScopes,
logger: c.Logger,
dialer: &net.Dialer{
Timeout: ldap.DefaultTimeout,
DualStack: true,
},
tlsConfig: tlsConfig,
timeout: 60, //XXX(longsleep): make timeout configuration.
limiter: rate.NewLimiter(100, 200), //XXX(longsleep): make rate limits configuration.
}
b.logger.WithField("ldap", fmt.Sprintf("%s://%s ", uri.Scheme, addr)).Infoln("ldap server identifier backend set up")
return b, nil
}
// RunWithContext implements the Backend interface.
func (b *LDAPIdentifierBackend) RunWithContext(ctx context.Context) error {
return nil
}
// Logon implements the Backend interface, enabling Logon with user name and
// password as provided. Requests are bound to the provided context.
func (b *LDAPIdentifierBackend) Logon(ctx context.Context, audience, username, password string) (bool, *string, *string, backends.UserFromBackend, error) {
loginAttributeName := b.attributeMapping[AttributeLogin]
if loginAttributeName == "" {
return false, nil, nil, nil, fmt.Errorf("ldap identifier backend logon impossible as no login attribute is set")
}
l, err := b.connect(ctx)
if err != nil {
return false, nil, nil, nil, fmt.Errorf("ldap identifier backend logon connect error: %v", err)
}
defer l.Close()
// Search for the given username.
entry, err := b.searchUsername(l, username, b.attributeMapping.attributes())
switch {
case ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject):
return false, nil, nil, nil, nil
}
if err != nil {
return false, nil, nil, nil, fmt.Errorf("ldap identifier backend logon search error: %v", err)
}
if !strings.EqualFold(entry.GetEqualFoldAttributeValue(loginAttributeName), username) {
return false, nil, nil, nil, fmt.Errorf("ldap identifier backend logon search returned wrong user")
}
// Bind as the user to verify the password.
err = l.Bind(entry.DN, password)
switch {
case ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials):
return false, nil, nil, nil, nil
}
if err != nil {
return false, nil, nil, nil, fmt.Errorf("ldap identifier backend logon error: %v", err)
}
entryID := b.entryIDFromEntry(b.attributeMapping, entry)
if entryID == "" {
return false, nil, nil, nil, fmt.Errorf("ldap identifier backend logon entry without entry ID: %v", entry.DN)
}
user, err := newLdapUser(entryID, b.attributeMapping, entry)
if err != nil {
return false, nil, nil, nil, fmt.Errorf("ldap identifier backend logon entry data error: %v", err)
}
// Use the users subject as user id.
userID := user.Subject()
b.logger.WithFields(logrus.Fields{
"username": user.Username(),
"id": userID,
}).Debugln("ldap identifier backend logon")
return true, &userID, nil, user, nil
}
// ResolveUserByUsername implements the Beckend interface, providing lookup for
// user by providing the username. Requests are bound to the provided context.
func (b *LDAPIdentifierBackend) ResolveUserByUsername(ctx context.Context, username string) (backends.UserFromBackend, error) {
loginAttributeName := b.attributeMapping[AttributeLogin]
if loginAttributeName == "" {
return nil, fmt.Errorf("ldap identifier backend resolve impossible as no login attribute is set")
}
l, err := b.connect(ctx)
if err != nil {
return nil, fmt.Errorf("ldap identifier backend resolve connect error: %v", err)
}
defer l.Close()
// Search for the given username.
entry, err := b.searchUsername(l, username, b.attributeMapping.attributes())
switch {
case ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject):
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("ldap identifier backend resolve search error: %v", err)
}
if !strings.EqualFold(entry.GetEqualFoldAttributeValue(loginAttributeName), username) {
return nil, fmt.Errorf("ldap identifier backend resolve search returned wrong user")
}
newEntryID := b.entryIDFromEntry(b.attributeMapping, entry)
user, err := newLdapUser(newEntryID, b.attributeMapping, entry)
if err != nil {
return nil, fmt.Errorf("ldap identifier backend resolve entry data error: %v", err)
}
return user, nil
}
// GetUser implements the Backend interface, providing user meta data retrieval
// for the user specified by the userID. Requests are bound to the provided
// context.
func (b *LDAPIdentifierBackend) GetUser(ctx context.Context, entryID string, sessionRef *string, requestedScopes map[string]bool) (backends.UserFromBackend, error) {
l, err := b.connect(ctx)
if err != nil {
return nil, fmt.Errorf("ldap identifier backend get user connect error: %v", err)
}
defer l.Close()
entry, err := b.getUser(l, entryID, b.attributeMapping.attributes())
if err != nil {
return nil, fmt.Errorf("ldap identifier backend get user error: %v", err)
}
newEntryID := b.entryIDFromEntry(b.attributeMapping, entry)
if !strings.EqualFold(newEntryID, entryID) {
return nil, fmt.Errorf("ldap identifier backend get user returned wrong user")
}
user, err := newLdapUser(newEntryID, b.attributeMapping, entry)
if err != nil {
return nil, fmt.Errorf("ldap identifier backend get user entry data error: %v", err)
}
return user, err
}
// RefreshSession implements the Backend interface.
func (b *LDAPIdentifierBackend) RefreshSession(ctx context.Context, userID string, sessionRef *string, claims map[string]interface{}) error {
return nil
}
// DestroySession implements the Backend interface providing destroy to KC session.
func (b *LDAPIdentifierBackend) DestroySession(ctx context.Context, sessionRef *string) error {
return nil
}
// UserClaims implements the Backend interface, providing user specific claims
// for the user specified by the userID.
func (b *LDAPIdentifierBackend) UserClaims(userID string, authorizedScopes map[string]bool) map[string]interface{} {
return nil
}
// ScopesSupported implements the Backend interface, providing supported scopes
// when running this backend.
func (b *LDAPIdentifierBackend) ScopesSupported() []string {
return b.supportedScopes
}
// ScopesMeta implements the Backend interface, providing meta data for
// supported scopes.
func (b *LDAPIdentifierBackend) ScopesMeta() *scopes.Scopes {
return nil
}
// Name implements the Backend interface.
func (b *LDAPIdentifierBackend) Name() string {
return ldapIdentifierBackendName
}
func (b *LDAPIdentifierBackend) connect(parentCtx context.Context) (*ldap.Conn, error) {
// A timeout for waiting for a limiter slot. The timeout also includes the
// time to connect to the LDAP server which as a consequence means that both
// getting a free slot and establishing the connection are one timeout.
ctx, cancel := context.WithTimeout(parentCtx, time.Duration(b.timeout)*time.Second)
defer cancel()
err := b.limiter.Wait(ctx)
if err != nil {
return nil, err
}
c, err := b.dialer.DialContext(ctx, "tcp", b.addr)
if err != nil {
return nil, ldap.NewError(ldap.ErrorNetwork, err)
}
var l *ldap.Conn
if b.isTLS {
sc := tls.Client(c, b.tlsConfig)
err = sc.Handshake()
if err != nil {
c.Close()
return nil, ldap.NewError(ldap.ErrorNetwork, err)
}
l = ldap.NewConn(sc, true)
} else {
l = ldap.NewConn(c, false)
}
l.Start()
// Bind with general user (which is preferably read only).
if b.bindDN != "" {
err = l.Bind(b.bindDN, b.bindPassword)
if err != nil {
return nil, err
}
}
return l, nil
}
func (b *LDAPIdentifierBackend) searchUsername(l *ldap.Conn, username string, attributes []string) (*ldap.Entry, error) {
base, filter := b.baseAndSearchFilterFromUsername(username)
// Search for the given username.
searchRequest := ldap.NewSearchRequest(
base,
b.scope, ldap.NeverDerefAliases, 1, b.timeout, false,
filter,
attributes,
nil,
)
sr, err := l.Search(searchRequest)
if err != nil {
return nil, err
}
switch len(sr.Entries) {
case 0:
// Nothing found.
return nil, ldap.NewError(ldap.LDAPResultNoSuchObject, err)
case 1:
// Exactly one found, success.
return sr.Entries[0], nil
default:
// Invalid when multiple matched.
return nil, fmt.Errorf("user too many entries returned")
}
}
func (b *LDAPIdentifierBackend) getUser(l *ldap.Conn, entryID string, attributes []string) (*ldap.Entry, error) {
base, filter := b.baseAndGetFilterFromEntryID(entryID)
if base == "" || filter == "" || entryID == "" {
return nil, fmt.Errorf("ldap identifier backend get user invalid user ID: %v", entryID)
}
scope := b.scope
if base == entryID {
// Ensure that scope is limited, when directly requesting an entry.
scope = ldap.ScopeBaseObject
}
// search for the given DN.
searchRequest := ldap.NewSearchRequest(
base,
scope, ldap.NeverDerefAliases, 1, b.timeout, false,
filter,
attributes,
nil,
)
sr, err := l.Search(searchRequest)
if err != nil {
return nil, err
}
if len(sr.Entries) != 1 {
return nil, fmt.Errorf("user does not exist or too many entries returned")
}
return sr.Entries[0], nil
}
func (b *LDAPIdentifierBackend) entryIDFromEntry(mapping ldapAttributeMapping, entry *ldap.Entry) string {
if b.entryIDMapping != nil {
// Encode as URL query.
values := url.Values{}
for _, k := range b.entryIDMapping {
v := entry.GetEqualFoldAttributeValues(k)
if len(v) > 0 {
values[k] = v
}
}
// URL encode values to string.
return values.Encode()
}
// Use DN by default is no mapping is set.
return entry.DN
}
func (b *LDAPIdentifierBackend) baseAndGetFilterFromEntryID(entryID string) (string, string) {
if b.entryIDMapping != nil {
// Parse entryID as URL encoded query values, and build & filter to search for them all.
if values, err := url.ParseQuery(entryID); err == nil {
filter := ""
for k, values := range values {
for _, value := range values {
filter = fmt.Sprintf("%s(%s=%s)", filter, k, ldap.EscapeFilter(value))
}
}
if filter != "" {
return b.baseDN, fmt.Sprintf("(&%s%s)", b.getFilter, filter)
}
}
// Failed to parse entry ID.
return "", ""
}
// Map DN to entryID.
_, err := ldap.ParseDN(entryID)
if err != nil {
return "", ""
}
return entryID, b.getFilter
}
func (b *LDAPIdentifierBackend) baseAndSearchFilterFromUsername(username string) (string, string) {
// Build search filter with username.
return b.baseDN, fmt.Sprintf(b.searchFilter, ldap.EscapeFilter(username))
}
@@ -0,0 +1,660 @@
/*
* Copyright 2021 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package libregraph
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/cevaris/ordered_map"
"github.com/libregraph/oidc-go"
"github.com/sirupsen/logrus"
konnect "github.com/libregraph/lico"
"github.com/libregraph/lico/config"
"github.com/libregraph/lico/identifier"
"github.com/libregraph/lico/identifier/backends"
"github.com/libregraph/lico/identifier/meta/scopes"
identityClients "github.com/libregraph/lico/identity/clients"
"github.com/libregraph/lico/utils"
)
const libreGraphIdentifierBackendName = "identifier-libregraph"
const (
OpenTypeExtensionType = "#microsoft.graph.openTypeExtension"
IdentityClaimsExtensionName = "libregraph.identityClaims"
IDTokenClaimsExtensionName = "libregraph.idTokenClaims"
AccessTokenClaimsExtensionName = "libregraph.accessTokenClaims"
RequestedScopesExtensionName = "libregraph.requestedScopes"
SessionExtensionName = "libregraph.session"
)
const (
apiPathMe = "/api/v1/me"
apiPathUsers = "/api/v1/users"
apiPathRevokeSignInSession = "/api/v1/me/revokeSignInSession"
)
var libreGraphSpportedScopes = []string{
oidc.ScopeProfile,
oidc.ScopeEmail,
konnect.ScopeUniqueUserID,
konnect.ScopeRawSubject,
}
type LibreGraphIdentifierBackend struct {
config *config.Config
supportedScopes []string
logger logrus.FieldLogger
tlsConfig *tls.Config
client *http.Client
baseURLMap *ordered_map.OrderedMap
useMultipleBackends bool
clients *identityClients.Registry
}
type libreGraphUser struct {
AccountEnabled bool `json:"accountEnabled"`
DisplayName string `json:"displayName"`
RawGivenName string `json:"givenName"`
ID string `json:"id"`
Mail string `json:"mail"`
Surname string `json:"surname"`
UserPrincipalName string `json:"userPrincipalName"`
Extensions []map[string]interface{} `json:"extensions"`
identityClaims map[string]interface{}
requestedScopes []string
requiredScopes []string
}
func decodeLibreGraphUser(r io.Reader) (*libreGraphUser, error) {
decoder := json.NewDecoder(r)
u := &libreGraphUser{}
if err := decoder.Decode(u); err != nil {
return nil, err
}
identityClaims := make(map[string]interface{})
identityClaims[konnect.IdentifiedUserIDClaim] = u.ID
var idTokenClaims map[string]interface{}
var accessTokenClaims map[string]interface{}
var requestedScopes []string
for _, extension := range u.Extensions {
if odataType, ok := extension["@odata.type"]; ok && odataType.(string) != OpenTypeExtensionType {
continue
}
if extensionName, ok := extension["extensionName"].(string); ok {
switch extensionName {
case IdentityClaimsExtensionName:
if v, ok := extension["claims"].(map[string]interface{}); ok {
for k, v := range v {
if k == konnect.InternalExtraIDTokenClaimsClaim || k == konnect.InternalExtraAccessTokenClaimsClaim {
// Ignore keys which areused internally by IDTokenClaimsExtensionName
// and AccessTokenClaimsExtensionName.
continue
}
identityClaims[k] = v
}
}
case IDTokenClaimsExtensionName:
if idTokenClaims == nil {
idTokenClaims = make(map[string]interface{})
}
if v, ok := extension["claims"].(map[string]interface{}); ok {
for k, v := range v {
idTokenClaims[k] = v
}
}
case AccessTokenClaimsExtensionName:
if accessTokenClaims == nil {
accessTokenClaims = make(map[string]interface{})
}
if v, ok := extension["claims"].(map[string]interface{}); ok {
for k, v := range v {
accessTokenClaims[k] = v
}
}
case RequestedScopesExtensionName:
if values, ok := extension["scopes"].([]interface{}); ok {
for _, v := range values {
if s, ok := v.(string); ok {
requestedScopes = append(requestedScopes, s)
}
}
}
case SessionExtensionName:
if sid, ok := extension[oidc.SessionIDClaim].(string); ok {
if sid != "" {
if accessTokenClaims == nil {
accessTokenClaims = make(map[string]interface{})
}
accessTokenClaims[oidc.SessionIDClaim] = sid
}
}
}
}
}
if idTokenClaims != nil {
// Inject claims as nested identity claim. The key is picket up by the
// token signer and used to extend ID token root claims.
identityClaims[konnect.InternalExtraIDTokenClaimsClaim] = idTokenClaims
}
if accessTokenClaims != nil {
// Inject claims as nested identity claims. The key is picked up by the
// token signer and userinfo handler to extend ID and access token root
// claims based on the request.
identityClaims[konnect.InternalExtraAccessTokenClaimsClaim] = accessTokenClaims
}
if requestedScopes != nil {
u.requestedScopes = requestedScopes
}
u.identityClaims = identityClaims
return u, nil
}
func (u *libreGraphUser) Subject() string {
return u.ID
}
func (u *libreGraphUser) Email() string {
return u.Mail
}
func (u *libreGraphUser) EmailVerified() bool {
return true
}
func (u *libreGraphUser) Name() string {
return u.DisplayName
}
func (u *libreGraphUser) FamilyName() string {
return u.Surname
}
func (u *libreGraphUser) GivenName() string {
return u.RawGivenName
}
func (u *libreGraphUser) Username() string {
return u.UserPrincipalName
}
func (u *libreGraphUser) UniqueID() string {
// Provide our ID as unique ID.
return u.ID
}
func (u *libreGraphUser) BackendClaims() map[string]interface{} {
return u.identityClaims
}
func (u *libreGraphUser) BackendScopes() []string {
return u.requestedScopes
}
func (u *libreGraphUser) RequiredScopes() []string {
return u.requiredScopes
}
func (u *libreGraphUser) setRequiredScopes(selectedScope string, scopeMap *ordered_map.OrderedMap) []string {
var requiredScopes []string
if selectedScope != "" {
requiredScopes = []string{selectedScope}
}
iter := scopeMap.IterFunc()
for kv, ok := iter(); ok; kv, ok = iter() {
if scope := kv.Key.(string); scope != selectedScope {
requiredScopes = append(requiredScopes, "!"+scope)
}
}
u.requiredScopes = requiredScopes
return requiredScopes
}
func (u *libreGraphUser) sessionID() string {
if accessTokenClaims, ok := u.identityClaims[konnect.InternalExtraAccessTokenClaimsClaim].(map[string]interface{}); ok {
if sessionID, withSessionID := accessTokenClaims[oidc.SessionIDClaim].(string); withSessionID {
if sessionID != "" {
return sessionID
}
}
}
return ""
}
func withSelectQuery(r *http.Request) {
if r.Form == nil {
r.Form = make(url.Values)
}
r.Form.Set("$select", "accountEnabled,displayName,givenName,id,mail,surname,userPrincipalName,extensions")
}
func NewLibreGraphIdentifierBackend(
c *config.Config,
tlsConfig *tls.Config,
baseURI string,
baseURIByScope *ordered_map.OrderedMap,
clients *identityClients.Registry,
) (*LibreGraphIdentifierBackend, error) {
if baseURI == "" {
return nil, fmt.Errorf("base uri must not be empty")
}
// Build supported scopes based on default scopes.
supportedScopes := make([]string, len(libreGraphSpportedScopes))
copy(supportedScopes, libreGraphSpportedScopes)
baseURLMap := ordered_map.NewOrderedMapWithArgs([]*ordered_map.KVPair{{
Key: "",
Value: baseURI,
}})
if baseURIByScope != nil {
iter := baseURIByScope.IterFunc()
for kv, ok := iter(); ok; kv, ok = iter() {
if kv.Key == "" {
return nil, fmt.Errorf("scoped base uri with empty scope is not allowed")
}
baseURLMap.Set(kv.Key, kv.Value)
}
}
transport := utils.HTTPTransportWithTLSClientConfig(tlsConfig)
transport.MaxIdleConns = 100
transport.IdleConnTimeout = 30 * time.Second
b := &LibreGraphIdentifierBackend{
config: c,
supportedScopes: supportedScopes,
logger: c.Logger,
tlsConfig: tlsConfig,
client: &http.Client{
Transport: transport,
Timeout: 60 * time.Second,
},
baseURLMap: baseURLMap,
useMultipleBackends: baseURLMap.Len() > 1,
clients: clients,
}
b.logger.WithField("map", baseURLMap).Infoln("libregraph server identified backend connection set up")
return b, nil
}
// RunWithContext implements the Backend interface.
func (b *LibreGraphIdentifierBackend) RunWithContext(ctx context.Context) error {
return nil
}
// Logon implements the Backend interface, enabling Logon with user name and
// password as provided. Requests are bound to the provided context.
func (b *LibreGraphIdentifierBackend) Logon(ctx context.Context, audience, username, password string) (bool, *string, *string, backends.UserFromBackend, error) {
record, _ := identifier.FromRecordContext(ctx)
var requestedScopes map[string]bool
if record != nil {
requestedScopes = record.HelloRequest.Scopes
}
// Inject implicit scopes set by client registration. This is needed here,
// as the requested scopes might not have the implicit scopes applied yet,
// based on the calling stack chain and since we use the scopes to select
// the backend.
registration, _ := b.clients.Get(ctx, audience)
if registration != nil {
_ = registration.ApplyImplicitScopes(requestedScopes)
}
selectedScope, meURL := b.getMeURL(requestedScopes)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, meURL, nil)
if err != nil {
return false, nil, nil, nil, fmt.Errorf("libregraph identifier backend logon request error: %w", err)
}
req.SetBasicAuth(username, password)
if record == nil {
record, _ = identifier.RecordFromRequestContext(ctx, b.config)
}
if record != nil {
// Inject HTTP headers.
if record.HelloRequest != nil {
if record.HelloRequest.Flow != "" {
req.Header.Set("X-Flow", record.HelloRequest.Flow)
}
if record.HelloRequest.RawScope != "" {
req.Header.Set("X-Scope", record.HelloRequest.RawScope)
}
if record.HelloRequest.RawPrompt != "" {
req.Header.Set("X-Prompt", record.HelloRequest.RawPrompt)
}
}
if record.RealIP != "" {
req.Header.Set("X-User-Real-IP", record.RealIP)
}
if record.UserAgent != "" {
req.Header.Set("X-User-Real-User-Agent", record.UserAgent)
}
}
req.Header.Set("User-Agent", utils.DefaultHTTPUserAgent)
// Inject select parameter.
withSelectQuery(req)
response, err := b.client.Do(req)
if err != nil {
return false, nil, nil, nil, fmt.Errorf("libregraph identifier backend logon request failed: %w", err)
}
defer response.Body.Close()
switch response.StatusCode {
case http.StatusOK:
// breaks
case http.StatusNotFound:
return false, nil, nil, nil, nil
case http.StatusUnauthorized:
return false, nil, nil, nil, nil
default:
return false, nil, nil, nil, fmt.Errorf("libregraph identifier backend logon request unexpected response status: %d", response.StatusCode)
}
user, err := decodeLibreGraphUser(response.Body)
if err != nil {
return false, nil, nil, nil, fmt.Errorf("libregraph identifier backend logon json decode error: %w", err)
}
if !user.AccountEnabled {
return false, nil, nil, nil, nil
}
requiredScopes := user.setRequiredScopes(selectedScope, b.baseURLMap)
// Use the users subject as user id.
userID := user.Subject()
sessionID := user.sessionID()
b.logger.WithFields(logrus.Fields{
"username": user.Username(),
"id": userID,
"scope": requiredScopes,
"sessionID": sessionID,
}).Debugln("libregraph identifier backend logon")
// Put the user into the record (if any).
if record != nil {
record.BackendUser = user
}
return true, &userID, &sessionID, user, nil
}
// GetUser implements the Backend interface, providing user meta data retrieval
// for the user specified by the userID. Requests are bound to the provided
// context.
func (b *LibreGraphIdentifierBackend) GetUser(ctx context.Context, entryID string, sessionRef *string, requestedScopes map[string]bool) (backends.UserFromBackend, error) {
record, _ := identifier.FromRecordContext(ctx)
if record != nil {
if record.BackendUser != nil {
if user, ok := record.BackendUser.(*libreGraphUser); ok {
// Fastpath, if logon previously injected the user.
if user.ID == entryID {
return user, nil
}
}
}
if requestedScopes == nil && record.HelloRequest != nil {
requestedScopes = record.HelloRequest.Scopes
}
}
selectedScope, userURL := b.getUserURL(requestedScopes)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, userURL+"/"+entryID, nil)
if err != nil {
return nil, fmt.Errorf("libregraph identifier backend get user request error: %w", err)
}
// Inject HTTP headers.
if requestedScopes != nil {
rawRequestedScopes := make([]string, 0)
for scope, enabled := range requestedScopes {
if enabled {
rawRequestedScopes = append(rawRequestedScopes, scope)
}
}
req.Header.Set("X-Scope", strings.Join(rawRequestedScopes, " "))
}
if sessionRef != nil {
sessionID := *sessionRef
if !strings.HasPrefix(sessionID, libreGraphIdentifierBackendName+":") {
// Only send the session ID if it is not a ref generated by lico.
req.Header.Set("X-SessionID", sessionID)
}
}
if record == nil {
record, _ = identifier.RecordFromRequestContext(ctx, b.config)
}
if record != nil {
if record.IdentifiedUser != nil {
if ok, ts := record.IdentifiedUser.LoggedOn(); ok {
req.Header.Set("X-User-Logon-At", ts.UTC().Format(http.TimeFormat))
}
}
if record.RealIP != "" {
req.Header.Set("X-User-Real-IP", record.RealIP)
}
if record.UserAgent != "" {
req.Header.Set("X-User-Real-User-Agent", record.UserAgent)
}
}
req.Header.Set("User-Agent", utils.DefaultHTTPUserAgent)
// Inject select parameter.
withSelectQuery(req)
response, err := b.client.Do(req)
if err != nil {
return nil, fmt.Errorf("libregraph identifier backend get user request failed: %w", err)
}
defer response.Body.Close()
switch response.StatusCode {
case http.StatusOK:
// breaks
case http.StatusNotFound:
return nil, nil
default:
return nil, fmt.Errorf("libregraph identifier backend get user request unexpected response status: %d", response.StatusCode)
}
user, err := decodeLibreGraphUser(response.Body)
if err != nil {
return nil, fmt.Errorf("libregraph identifier backend logon json decode error: %w", err)
}
if !user.AccountEnabled {
return nil, nil
}
user.setRequiredScopes(selectedScope, b.baseURLMap)
return user, nil
}
// ResolveUserByUsername implements the Backend interface, providing lookup for
// user by providing the username. Requests are bound to the provided context.
func (b *LibreGraphIdentifierBackend) ResolveUserByUsername(ctx context.Context, username string) (backends.UserFromBackend, error) {
// Libregraph backend accept both user name and ID lookups, so this is
// the same as GetUser without a session.
return b.GetUser(ctx, username, nil, nil)
}
// RefreshSession implements the Backend interface.
func (b *LibreGraphIdentifierBackend) RefreshSession(ctx context.Context, userID string, sessionRef *string, claims map[string]interface{}) error {
user, err := b.GetUser(ctx, userID, sessionRef, nil)
if err != nil {
return err
}
if user == nil {
return fmt.Errorf("refresh session did not yield a user")
}
return nil
}
// DestroySession implements the Backend interface providing explicit revoke
// of the backend session.
func (b *LibreGraphIdentifierBackend) DestroySession(ctx context.Context, sessionRef *string) error {
var requestedScopes map[string]bool
record, _ := identifier.FromRecordContext(ctx)
if record != nil {
if record.HelloRequest != nil {
requestedScopes = record.HelloRequest.Scopes
}
}
_, revokeSessionURL := b.getRevokeSigninSessionURL(requestedScopes)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, revokeSessionURL, http.NoBody)
if err != nil {
return fmt.Errorf("libregraph identifier backend destroy session request error: %w", err)
}
if sessionRef != nil {
sessionID := *sessionRef
if !strings.HasPrefix(sessionID, libreGraphIdentifierBackendName+":") {
// Only send the session ID if it is not a ref generated by lico.
req.Header.Set("X-SessionID", sessionID)
}
}
if record == nil {
record, _ = identifier.RecordFromRequestContext(ctx, b.config)
}
if record != nil {
// Inject HTTP headers.
if record.RealIP != "" {
req.Header.Set("X-User-Real-IP", record.RealIP)
}
if record.UserAgent != "" {
req.Header.Set("X-User-Real-User-Agent", record.UserAgent)
}
}
req.Header.Set("User-Agent", utils.DefaultHTTPUserAgent)
response, err := b.client.Do(req)
if err != nil {
return fmt.Errorf("libregraph identifier backend destroy session request failed: %w", err)
}
defer response.Body.Close()
switch response.StatusCode {
case http.StatusOK:
// breaks
case http.StatusNotFound:
return nil
case http.StatusUnauthorized:
return nil
default:
return fmt.Errorf("libregraph identifier backend logon request unexpected response status: %d", response.StatusCode)
}
return nil
}
// UserClaims implements the Backend interface, providing user specific claims
// for the user specified by the userID.
func (b *LibreGraphIdentifierBackend) UserClaims(userID string, authorizedScopes map[string]bool) map[string]interface{} {
return nil
}
// ScopesSupported implements the Backend interface, providing supported scopes
// when running this backend.
func (b *LibreGraphIdentifierBackend) ScopesSupported() []string {
return b.supportedScopes
}
// ScopesMeta implements the Backend interface, providing meta data for
// supported scopes.
func (b *LibreGraphIdentifierBackend) ScopesMeta() *scopes.Scopes {
return nil
}
// Name implements the Backend interface.
func (b *LibreGraphIdentifierBackend) Name() string {
return libreGraphIdentifierBackendName
}
func (b *LibreGraphIdentifierBackend) getBaseURL(requestedScopes map[string]bool) (string, string) {
if b.useMultipleBackends && requestedScopes != nil {
// Loop through configured backends for each requested scope.
for s, v := range requestedScopes {
if !v {
continue
}
if u, ok := b.baseURLMap.Get(s); ok {
return s, u.(string)
}
}
}
// If nothing found, return default.
u, _ := b.baseURLMap.Get("")
return "", u.(string)
}
func (b *LibreGraphIdentifierBackend) getMeURL(requestedScopes map[string]bool) (string, string) {
scope, baseURL := b.getBaseURL(requestedScopes)
return scope, baseURL + apiPathMe
}
func (b *LibreGraphIdentifierBackend) getUserURL(requestedScopes map[string]bool) (string, string) {
scope, baseURL := b.getBaseURL(requestedScopes)
return scope, baseURL + apiPathUsers
}
func (b *LibreGraphIdentifierBackend) getRevokeSigninSessionURL(requestedScopes map[string]bool) (string, string) {
scope, baseURL := b.getBaseURL(requestedScopes)
return scope, baseURL + apiPathRevokeSignInSession
}
+31
View File
@@ -0,0 +1,31 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package identifier
// Additional claims as used by the identifier in its own tokens.
const (
SessionIDClaim = "sid"
LogonRefClaim = "lref"
ExternalAuthorityIDClaim = "eaid"
LockedScopesClaim = "lscp"
)
// History claims previously used by the identifier in its own tokens.
const (
ObsoleteUserClaimsClaim = "claims"
)
+56
View File
@@ -0,0 +1,56 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package identifier
import (
"net/http"
"net/url"
"github.com/libregraph/lico/config"
"github.com/libregraph/lico/identifier/backends"
)
// Config defines a Server's configuration settings.
type Config struct {
Config *config.Config
BaseURI *url.URL
ScopesConf string
LogonCookieName string
LogonCookieSameSite http.SameSite
ConsentCookieSameSite http.SameSite
StateCookieSameSite http.SameSite
PathPrefix string
StaticFolder string
WebAppDisabled bool
AuthorizationEndpointURI *url.URL
SignedOutEndpointURI *url.URL
DefaultBannerLogo []byte
DefaultSignInPageText *string
DefaultSignInPageLogoURI *string
DefaultUsernameHintText *string
UILocales []string
Backend backends.Backend
}
+89
View File
@@ -0,0 +1,89 @@
/*
* Copyright 2021 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package identifier
import (
"context"
"net"
"net/http"
konnect "github.com/libregraph/lico"
"github.com/libregraph/lico/config"
"github.com/libregraph/lico/identifier/backends"
"github.com/libregraph/lico/utils"
)
// Record is the struct which the identifier puts into the context.
type Record struct {
HelloRequest *HelloRequest
RealIP string
UserAgent string
BackendUser backends.UserFromBackend
IdentifiedUser *IdentifiedUser
}
func NewRecord(req *http.Request, c *config.Config) *Record {
record := &Record{
UserAgent: req.UserAgent(),
}
trusted, _ := utils.IsRequestFromTrustedSource(req, c.TrustedProxyIPs, c.TrustedProxyNets)
if trusted {
record.RealIP = req.Header.Get("X-Real-Ip")
}
if record.RealIP == "" {
if ip, _, err := net.SplitHostPort(req.RemoteAddr); err == nil {
record.RealIP = ip
}
}
return record
}
// key is an unexported type for keys defined in this package.
// This prevents collisions with keys defined in other packages.
type key int
// Keys for context data.
// Unexported; Clients use identifier.New{?}Context and
// identifier.From{?}Context functions instead of using these keys directly.
const (
recordKey key = iota
)
// NewRecordContext returns a new Context that carries the Record.
func NewRecordContext(ctx context.Context, record *Record) context.Context {
return context.WithValue(ctx, recordKey, record)
}
// FromRecordContext returns the Record value stored in ctx, if any.
func FromRecordContext(ctx context.Context) (*Record, bool) {
record, ok := ctx.Value(recordKey).(*Record)
return record, ok
}
// RecordFromRequestContext returns a new Record value based on the request
// stored in ctx, if any.
func RecordFromRequestContext(ctx context.Context, c *config.Config) (*Record, bool) {
if req, ok := konnect.FromRequestContext(ctx); ok {
return NewRecord(req, c), true
}
return nil, false
}
+202
View File
@@ -0,0 +1,202 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package identifier
import (
"encoding/base64"
"net/http"
"golang.org/x/crypto/blake2b"
)
const (
consentCookieNamePrefix = "__Secure-KKTC" // Kopano Konnect Temorary Consent
stateCookieNamePrefix = "__Secure-KKTS" // Kopano Konnect Temporary State
)
func (i *Identifier) setLogonCookie(rw http.ResponseWriter, value string) error {
cookie := http.Cookie{
Name: i.logonCookieName,
Value: value,
Path: i.pathPrefix + "/identifier/_/",
Secure: true,
HttpOnly: true,
SameSite: i.logonCookieSameSite,
}
http.SetCookie(rw, &cookie)
return nil
}
func (i *Identifier) getLogonCookie(req *http.Request) (*http.Cookie, error) {
return req.Cookie(i.logonCookieName)
}
func (i *Identifier) removeLogonCookie(rw http.ResponseWriter) error {
cookie := http.Cookie{
Name: i.logonCookieName,
Path: i.pathPrefix + "/identifier/_/",
Secure: true,
HttpOnly: true,
SameSite: i.logonCookieSameSite,
Expires: farPastExpiryTime,
}
http.SetCookie(rw, &cookie)
return nil
}
func (i *Identifier) setConsentCookie(rw http.ResponseWriter, cr *ConsentRequest, value string) error {
name, err := i.getConsentCookieName(cr)
if err != nil {
return err
}
cookie := http.Cookie{
Name: name,
Value: value,
MaxAge: 60,
Path: i.pathPrefix + "/identifier/_/",
Secure: true,
HttpOnly: true,
SameSite: i.consentCookieSameSite,
}
http.SetCookie(rw, &cookie)
return nil
}
func (i *Identifier) getConsentCookie(req *http.Request, cr *ConsentRequest) (*http.Cookie, error) {
name, err := i.getConsentCookieName(cr)
if err != nil {
return nil, err
}
return req.Cookie(name)
}
func (i *Identifier) removeConsentCookie(rw http.ResponseWriter, req *http.Request, cr *ConsentRequest) error {
name, err := i.getConsentCookieName(cr)
if err != nil {
return nil
}
cookie := http.Cookie{
Name: name,
Path: i.pathPrefix + "/identifier/_/",
Secure: true,
HttpOnly: true,
SameSite: i.consentCookieSameSite,
Expires: farPastExpiryTime,
}
http.SetCookie(rw, &cookie)
return nil
}
func (i *Identifier) getConsentCookieName(cr *ConsentRequest) (string, error) {
// Consent cookie names are based on parameters in the request.
hasher, err := blake2b.New256(nil)
if err != nil {
return "", err
}
hasher.Write([]byte(cr.State))
hasher.Write([]byte("h"))
hasher.Write([]byte(cr.ClientID))
hasher.Write([]byte("e"))
hasher.Write([]byte(cr.RawRedirectURI))
hasher.Write([]byte("l"))
hasher.Write([]byte(cr.Ref))
hasher.Write([]byte("o"))
hasher.Write([]byte(cr.Nonce))
name := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil))
return consentCookieNamePrefix + "-" + name, nil
}
func (i *Identifier) setStateCookie(rw http.ResponseWriter, scope string, state string, value string) error {
name, err := i.getStateCookieName(state)
if err != nil {
return err
}
cookie := http.Cookie{
Name: name,
Value: value,
MaxAge: 600,
Path: i.pathPrefix + "/identifier/" + scope,
Secure: true,
HttpOnly: true,
SameSite: i.stateCookieSameSite,
}
http.SetCookie(rw, &cookie)
return nil
}
func (i *Identifier) getStateCookie(req *http.Request, state string) (*http.Cookie, error) {
name, err := i.getStateCookieName(state)
if err != nil {
return nil, err
}
return req.Cookie(name)
}
func (i *Identifier) removeStateCookie(rw http.ResponseWriter, req *http.Request, scope string, state string) error {
name, err := i.getStateCookieName(state)
if err != nil {
return nil
}
cookie := http.Cookie{
Name: name,
Path: i.pathPrefix + "/identifier/" + scope,
Secure: true,
HttpOnly: true,
SameSite: i.stateCookieSameSite,
Expires: farPastExpiryTime,
}
http.SetCookie(rw, &cookie)
return nil
}
func (i *Identifier) getStateCookieName(state string) (string, error) {
// State cookie names are based on the state value.
hasher, err := blake2b.New256(nil)
if err != nil {
return "", err
}
hasher.Write([]byte(state))
name := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil))
return stateCookieNamePrefix + "-" + name, nil
}
+27
View File
@@ -0,0 +1,27 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package identifier
const (
// FlowOIDC is the string value for the oidc flow.
FlowOIDC = "oidc"
// FlowOAuth is the string value for the oauth flow.
FlowOAuth = "oauth"
// FlowConsent is the string value for the consent flow.
FlowConsent = "consent"
)
+526
View File
@@ -0,0 +1,526 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package identifier
import (
"encoding/json"
"encoding/xml"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/sirupsen/logrus"
konnect "github.com/libregraph/lico"
"github.com/libregraph/lico/identity/authorities"
"github.com/libregraph/lico/utils"
)
func (i *Identifier) staticHandler(handler http.Handler, cache bool) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
addCommonResponseHeaders(rw.Header())
if cache {
rw.Header().Set("Cache-Control", "max-age=3153600, public")
} else {
rw.Header().Set("Cache-Control", "no-cache, max-age=0, public")
}
if strings.HasSuffix(req.URL.Path, "/") {
// Do not serve folder-ish resources.
i.ErrorPage(rw, http.StatusNotFound, "", "")
return
}
handler.ServeHTTP(rw, req)
})
}
func (i *Identifier) secureHandler(handler http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
var err error
// TODO(longsleep): Add support for X-Forwareded-Host with trusted proxy.
// NOTE: this does not protect from DNS rebinding. Protection for that
// should be added at the frontend proxy.
requiredHost := req.Host
if host, port, splitErr := net.SplitHostPort(requiredHost); splitErr == nil {
if port == "443" {
// Ignore the port 443 as it is the default port and it is
// usually not part of any of the urls. It might be in the
// request for HTTP/3 requests.
requiredHost = host
}
}
// This follows https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet
for {
if req.Header.Get("Kopano-Konnect-XSRF") != "1" {
err = fmt.Errorf("missing xsrf header")
break
}
origin := req.Header.Get("Origin")
referer := req.Header.Get("Referer")
// Require either Origin and Referer header.
// NOTE(longsleep): Firefox does not send Origin header for POST
// requests when on the same domain - this is fuck (tm). See
// https://bugzilla.mozilla.org/show_bug.cgi?id=446344 for reference.
if origin == "" && referer == "" {
err = fmt.Errorf("missing origin or referer header")
break
}
if origin != "" {
originURL, urlParseErr := url.Parse(origin)
if urlParseErr != nil {
err = fmt.Errorf("invalid origin value: %v", urlParseErr)
break
}
if originURL.Host != requiredHost {
err = fmt.Errorf("origin does not match request URL")
break
}
} else if referer != "" {
refererURL, urlParseErr := url.Parse(referer)
if urlParseErr != nil {
err = fmt.Errorf("invalid referer value: %v", urlParseErr)
break
}
if refererURL.Host != requiredHost {
err = fmt.Errorf("referer does not match request URL")
break
}
} else {
i.logger.WithFields(logrus.Fields{
"host": requiredHost,
"user-agent": req.UserAgent(),
}).Warn("identifier HTTP request is insecure with no Origin and Referer")
}
handler.ServeHTTP(rw, req)
return
}
if err != nil {
i.logger.WithError(err).WithFields(logrus.Fields{
"host": requiredHost,
"referer": req.Referer(),
"user-agent": req.UserAgent(),
"origin": req.Header.Get("Origin"),
}).Warn("rejecting identifier HTTP request")
}
i.ErrorPage(rw, http.StatusBadRequest, "", "")
})
}
func (i *Identifier) handleIdentifier(rw http.ResponseWriter, req *http.Request) {
addCommonResponseHeaders(rw.Header())
addNoCacheResponseHeaders(rw.Header())
err := req.ParseForm()
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to decode request")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request")
return
}
switch req.Form.Get("flow") {
case FlowOIDC, FlowOAuth, "":
if req.Form.Get("identifier") != MustBeSignedIn {
// Check if there is a default authority, if so use that.
authority := i.authorities.Default(req.Context())
if authority != nil {
switch authority.AuthorityType {
case authorities.AuthorityTypeOIDC:
i.writeOAuth2Start(rw, req, authority)
case authorities.AuthorityTypeSAML2:
i.writeSAML2Start(rw, req, authority)
default:
i.ErrorPage(rw, http.StatusNotImplemented, "", "unknown authority type")
}
return
}
}
}
// Show default.
i.writeWebappIndexHTML(rw, req)
}
func (i *Identifier) handleLogon(rw http.ResponseWriter, req *http.Request) {
decoder := json.NewDecoder(req.Body)
var r LogonRequest
err := decoder.Decode(&r)
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to decode logon request")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request JSON")
return
}
var user *IdentifiedUser
response := &LogonResponse{
State: r.State,
}
addNoCacheResponseHeaders(rw.Header())
record := NewRecord(req, i.Config.Config)
if r.Hello != nil {
err = r.Hello.parse()
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to parse logon request hello")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to parse request values")
return
}
record.HelloRequest = r.Hello
}
req = req.WithContext(NewRecordContext(konnect.NewRequestContext(req.Context(), req), record))
// Params is an array like this [$username, $password, $mode], defining a
// extensible way to extend login modes over time. The minimal length of
// the params array is 1 with only [$username]. Second field is the password
// but its interpretation depends on the third field ($mode). The rest of the
// fields are mode specific.
params := r.Params
for {
paramSize := len(params)
if paramSize == 0 {
i.ErrorPage(rw, http.StatusBadRequest, "", "params required")
break
}
if paramSize >= 3 && params[1] == "" && params[2] == ModeLogonUsernameEmptyPasswordCookie {
// Special mode to allow when same user is logged in via cookie. This
// is used in the select account page logon flow with empty password.
identifiedUser, cookieErr := i.GetUserFromLogonCookie(req.Context(), req, 0, true)
if cookieErr != nil {
i.logger.WithError(cookieErr).Debugln("identifier failed to decode logon cookie in logon request")
}
if identifiedUser != nil {
if identifiedUser.Username() == params[0] {
user = identifiedUser
break
}
}
}
audience := ""
if r.Hello != nil {
audience = r.Hello.ClientID
}
if paramSize < 3 {
// Unsupported logon mode.
break
}
if params[1] == "" {
// Empty password, stop here - never allowed in any mode.
break
}
switch params[2] {
case ModeLogonUsernamePassword:
// Username and password validation mode.
logonedUser, logonErr := i.logonUser(req.Context(), audience, params[0], params[1])
if logonErr != nil {
i.logger.WithError(logonErr).Errorln("identifier failed to logon with backend")
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to logon")
return
}
user = logonedUser
default:
i.logger.Debugln("identifier unknown logon mode: %v", params[2])
}
break
}
if user == nil || user.Subject() == "" {
rw.Header().Set("Kopano-Konnect-State", response.State)
rw.WriteHeader(http.StatusNoContent)
return
}
// Get user meta data.
// TODO(longsleep): This is an additional request to the backend. This
// should be avoided. Best would be if the backend would return everything
// in one shot (TODO in core).
err = i.updateUser(req.Context(), user, nil)
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to update user data in logon request")
}
// Set logon time.
user.logonAt = time.Now()
if r.Hello != nil {
hello, errHello := i.writeHelloResponse(rw, req, r.Hello, user)
if errHello != nil {
i.logger.WithError(errHello).Debugln("rejecting identifier logon request")
i.ErrorPage(rw, http.StatusBadRequest, "", errHello.Error())
return
}
if !hello.Success {
rw.Header().Set("Kopano-Konnect-State", response.State)
rw.WriteHeader(http.StatusNoContent)
return
}
response.Hello = hello
}
err = i.SetUserToLogonCookie(req.Context(), rw, user)
if err != nil {
i.logger.WithError(err).Errorln("failed to serialize logon ticket")
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to serialize logon ticket")
return
}
response.Success = true
err = utils.WriteJSON(rw, http.StatusOK, response, "")
if err != nil {
i.logger.WithError(err).Errorln("logon request failed writing response")
}
}
func (i *Identifier) handleLogoff(rw http.ResponseWriter, req *http.Request) {
decoder := json.NewDecoder(req.Body)
var r StateRequest
err := decoder.Decode(&r)
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to decode logoff request")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request JSON")
return
}
addNoCacheResponseHeaders(rw.Header())
ctx := req.Context()
u, err := i.GetUserFromLogonCookie(ctx, req, 0, false)
if err != nil {
i.logger.WithError(err).Warnln("identifier logoff failed to get logon from ticket")
}
err = i.UnsetLogonCookie(ctx, u, rw)
if err != nil {
i.logger.WithError(err).Errorln("identifier failed to set logoff ticket")
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to set logoff ticket")
return
}
response := &StateResponse{
State: r.State,
Success: true,
}
err = utils.WriteJSON(rw, http.StatusOK, response, "")
if err != nil {
i.logger.WithError(err).Errorln("logoff request failed writing response")
}
}
func (i *Identifier) handleConsent(rw http.ResponseWriter, req *http.Request) {
decoder := json.NewDecoder(req.Body)
var r ConsentRequest
err := decoder.Decode(&r)
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to decode consent request")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request JSON")
return
}
addNoCacheResponseHeaders(rw.Header())
consent := &Consent{
Allow: r.Allow,
}
if r.Allow {
consent.RawScope = r.RawScope
}
err = i.SetConsentToConsentCookie(req.Context(), rw, &r, consent)
if err != nil {
i.logger.WithError(err).Errorln("failed to serialize consent ticket")
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to serialize consent ticket")
return
}
if !r.Allow {
rw.Header().Set("Kopano-Konnect-State", r.State)
rw.WriteHeader(http.StatusNoContent)
return
}
response := &StateResponse{
State: r.State,
Success: true,
}
err = utils.WriteJSON(rw, http.StatusOK, response, "")
if err != nil {
i.logger.WithError(err).Errorln("logoff request failed writing response")
}
}
func (i *Identifier) handleHello(rw http.ResponseWriter, req *http.Request) {
decoder := json.NewDecoder(req.Body)
var r HelloRequest
err := decoder.Decode(&r)
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to decode hello request")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request JSON")
return
}
err = r.parse()
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to parse hello request")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to parse request values")
return
}
addNoCacheResponseHeaders(rw.Header())
response, err := i.writeHelloResponse(rw, req, &r, nil)
if err != nil {
i.logger.WithError(err).Debugln("rejecting identifier hello request")
i.ErrorPage(rw, http.StatusBadRequest, "", err.Error())
return
}
err = utils.WriteJSON(rw, http.StatusOK, response, "")
if err != nil {
i.logger.WithError(err).Errorln("hello request failed writing response")
}
}
func (i *Identifier) handleTrampolin(rw http.ResponseWriter, req *http.Request) {
if !strings.HasSuffix(req.URL.Path, ".js") {
err := req.ParseForm()
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to decode trampolin request")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request parameters")
return
}
sd, err := i.GetStateFromStateCookie(req.Context(), rw, req, "trampolin", req.Form.Get("state"))
if err != nil {
i.ErrorPage(rw, http.StatusBadRequest, "", err.Error())
return
}
if sd == nil || sd.Trampolin == nil {
i.ErrorPage(rw, http.StatusBadRequest, "", "no state")
return
}
scope := sd.Trampolin.Scope
uri, _ := url.Parse(sd.Trampolin.URI)
sd.Trampolin = nil
err = i.SetStateToStateCookie(req.Context(), rw, scope, sd)
if err != nil {
i.logger.WithError(err).Errorln("failed to write trampolin state cookie")
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to write trampolin state cookie")
return
}
i.writeTrampolinHTML(rw, req, uri)
} else {
i.writeTrampolinScript(rw, req)
}
}
func (i *Identifier) handleOAuth2Start(rw http.ResponseWriter, req *http.Request) {
err := req.ParseForm()
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to decode oauth2 start request")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request parameters")
return
}
var authority *authorities.Details
if authorityID := req.Form.Get("authority_id"); authorityID != "" {
authority, _ = i.authorities.Lookup(req.Context(), authorityID)
}
i.writeOAuth2Start(rw, req, authority)
}
func (i *Identifier) handleOAuth2Cb(rw http.ResponseWriter, req *http.Request) {
err := req.ParseForm()
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to decode oauth2 cb request")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request parameters")
return
}
i.writeOAuth2Cb(rw, req)
}
func (i *Identifier) handleSAML2Metadata(rw http.ResponseWriter, req *http.Request) {
authorityDetails := i.authorities.Default(req.Context())
if authorityDetails == nil || authorityDetails.AuthorityType != authorities.AuthorityTypeSAML2 {
i.ErrorPage(rw, http.StatusNotFound, "", "saml not configured")
return
}
metadata := authorityDetails.Metadata()
if metadata == nil {
i.ErrorPage(rw, http.StatusNotFound, "", "saml has no meta data")
return
}
buf, _ := xml.MarshalIndent(metadata, "", " ")
rw.Header().Set("Content-Type", "application/samlmetadata+xml")
rw.WriteHeader(http.StatusOK)
rw.Write([]byte(xml.Header))
rw.Write(buf)
}
func (i *Identifier) handleSAML2AssertionConsumerService(rw http.ResponseWriter, req *http.Request) {
err := req.ParseForm()
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to decode saml2 acs request")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request parameters")
return
}
i.writeSAML2AssertionConsumerService(rw, req)
}
func (i *Identifier) handleSAML2SingleLogoutService(rw http.ResponseWriter, req *http.Request) {
err := req.ParseForm()
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to decode saml2 slo request")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request parameters")
return
}
if _, ok := req.Form["SAMLRequest"]; ok {
i.writeSAMLSingleLogoutServiceRequest(rw, req)
} else if _, ok := req.Form["SAMLResponse"]; ok {
i.writeSAMLSingleLogoutServiceResponse(rw, req)
} else {
i.ErrorPage(rw, http.StatusBadRequest, "", "neither SAMLRequest nor SAMLResponse parameter found")
}
}
+774
View File
@@ -0,0 +1,774 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package identifier
import (
"bytes"
"context"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"os"
"strings"
"time"
mapset "github.com/deckarep/golang-set"
"github.com/go-jose/go-jose/v3"
"github.com/go-jose/go-jose/v3/jwt"
"github.com/gorilla/mux"
"github.com/longsleep/rndm"
"github.com/sirupsen/logrus"
"github.com/libregraph/oidc-go"
konnect "github.com/libregraph/lico"
"github.com/libregraph/lico/identifier/backends"
"github.com/libregraph/lico/identifier/meta"
"github.com/libregraph/lico/identifier/meta/scopes"
"github.com/libregraph/lico/identity"
"github.com/libregraph/lico/identity/authorities"
"github.com/libregraph/lico/identity/clients"
"github.com/libregraph/lico/managers"
"github.com/libregraph/lico/utils"
)
// audienceMarker defines the value which gets included in logon cookies. Valid
// logon cookies must have the first value of this list in their audience claim.
// Increment this value whenever logon cookie claims and format changes in
// non-backwards compatible ways. User will have to sign in again to get a new
// cookie.
var audienceMarker = jwt.Audience([]string{"2019012201"})
// Identifier defines a identification login area with its endpoints using
// a Kopano Core server as backend logon provider.
type Identifier struct {
Config *Config
baseURI *url.URL
pathPrefix string
staticFolder string
scopesConf string
webappIndexHTML []byte
logonCookieName string
logonCookieSameSite http.SameSite
consentCookieSameSite http.SameSite
stateCookieSameSite http.SameSite
authorizationEndpointURI *url.URL
signedOutEndpointURI *url.URL
oauth2CbEndpointURI *url.URL
encrypter jose.Encrypter
recipient *jose.Recipient
backend backends.Backend
clients *clients.Registry
authorities *authorities.Registry
meta *meta.Meta
defaultBannerLogo *string
onSetLogonCallbacks []func(ctx context.Context, rw http.ResponseWriter, user identity.User) error
onUnsetLogonCallbacks []func(ctx context.Context, rw http.ResponseWriter) error
logger logrus.FieldLogger
router *mux.Router
}
// NewIdentifier returns a new Identifier.
func NewIdentifier(c *Config) (*Identifier, error) {
staticFolder := c.StaticFolder
var webappIndexHTML = make([]byte, 0)
if !c.WebAppDisabled {
fn := staticFolder + "/index.html"
if _, statErr := os.Stat(fn); os.IsNotExist(statErr) {
return nil, fmt.Errorf("identifier client index.html not found: %w", statErr)
}
readData, readErr := ioutil.ReadFile(fn)
if readErr != nil {
return nil, fmt.Errorf("identifier failed to read client index.html: %w", readErr)
}
webappIndexHTML = bytes.Replace(readData, []byte("__PATH_PREFIX__"), []byte(c.PathPrefix), 1)
}
oauth2CbEndpointURI, _ := url.Parse(c.BaseURI.String())
oauth2CbEndpointURI.Path = c.PathPrefix + "/identifier/oauth2/cb"
i := &Identifier{
Config: c,
baseURI: c.BaseURI,
pathPrefix: c.PathPrefix,
staticFolder: staticFolder,
scopesConf: c.ScopesConf,
webappIndexHTML: webappIndexHTML,
logonCookieName: c.LogonCookieName,
logonCookieSameSite: c.LogonCookieSameSite,
consentCookieSameSite: c.ConsentCookieSameSite,
stateCookieSameSite: c.StateCookieSameSite,
authorizationEndpointURI: c.AuthorizationEndpointURI,
signedOutEndpointURI: c.SignedOutEndpointURI,
oauth2CbEndpointURI: oauth2CbEndpointURI,
backend: c.Backend,
onSetLogonCallbacks: make([]func(ctx context.Context, rw http.ResponseWriter, user identity.User) error, 0),
onUnsetLogonCallbacks: make([]func(ctx context.Context, rw http.ResponseWriter) error, 0),
logger: c.Config.Logger,
}
var err error
i.meta = &meta.Meta{}
i.meta.Scopes, err = scopes.NewScopesFromFile(i.scopesConf, i.logger)
if err != nil {
return nil, err
}
if c.DefaultBannerLogo != nil {
defaultBannerLogo, err := encodeImageAsDataURL(c.DefaultBannerLogo)
if err != nil {
return nil, fmt.Errorf("failed to encode default banner logo: %w", err)
}
i.defaultBannerLogo = &defaultBannerLogo
}
i.meta.Scopes.Extend(c.Backend.ScopesMeta())
return i, nil
}
// RegisterManagers registers the provided managers,
func (i *Identifier) RegisterManagers(mgrs *managers.Managers) error {
i.clients = mgrs.Must("clients").(*clients.Registry)
i.authorities = mgrs.Must("authorities").(*authorities.Registry)
if service, ok := i.backend.(managers.ServiceUsesManagers); ok {
err := service.RegisterManagers(mgrs)
if err != nil {
return err
}
}
return nil
}
// AddRoutes adds the endpoint routes of the accociated Identifier to the
// provided router with the provided context.
func (i *Identifier) AddRoutes(ctx context.Context, router *mux.Router) {
r := router.PathPrefix(i.pathPrefix).Subrouter()
r.PathPrefix("/static/").Handler(i.staticHandler(http.StripPrefix(i.pathPrefix, http.FileServer(http.Dir(i.staticFolder))), true))
r.Handle("/service-worker.js", i.staticHandler(http.StripPrefix(i.pathPrefix, http.FileServer(http.Dir(i.staticFolder))), false))
r.Handle("/identifier", http.HandlerFunc(i.handleIdentifier)).Methods(http.MethodGet).Name("index")
r.Handle("/chooseaccount", i).Methods(http.MethodGet).Name("chooseaccount")
r.Handle("/consent", i).Methods(http.MethodGet).Name("consent")
r.Handle("/welcome", i).Methods(http.MethodGet).Name("welcome")
r.Handle("/goodbye", i).Methods(http.MethodGet).Name("goodbye")
r.Handle("/index.html", i).Methods(http.MethodGet) // For service worker.
r.Handle("/identifier/_/logon", i.secureHandler(http.HandlerFunc(i.handleLogon))).Methods(http.MethodPost)
r.Handle("/identifier/_/logoff", i.secureHandler(http.HandlerFunc(i.handleLogoff))).Methods(http.MethodPost)
r.Handle("/identifier/_/hello", i.secureHandler(http.HandlerFunc(i.handleHello))).Methods(http.MethodPost)
r.Handle("/identifier/_/consent", i.secureHandler(http.HandlerFunc(i.handleConsent))).Methods(http.MethodPost)
r.Handle("/identifier/oauth2/start", http.HandlerFunc(i.handleOAuth2Start)).Methods(http.MethodGet).Name("oauth2/start")
r.Handle("/identifier/oauth2/cb", http.HandlerFunc(i.handleOAuth2Cb)).Methods(http.MethodGet).Name("oauth2/cb")
r.Handle("/identifier/saml2/metadata", http.HandlerFunc(i.handleSAML2Metadata))
r.Handle("/identifier/saml2/acs", http.HandlerFunc(i.handleSAML2AssertionConsumerService)).Methods(http.MethodPost).Name("saml2/acs")
r.Handle("/identifier/_/saml2/slo", http.HandlerFunc(i.handleSAML2SingleLogoutService)).Methods(http.MethodGet).Name("saml2/slo")
r.Handle("/identifier/trampolin", http.HandlerFunc(i.handleTrampolin)).Methods(http.MethodGet).Name("trampolin")
r.Handle("/identifier/trampolin/trampolin.js", http.HandlerFunc(i.handleTrampolin)).Methods(http.MethodGet)
i.router = r
if i.backend != nil {
i.backend.RunWithContext(ctx)
}
}
// ServeHTTP implements the http.Handler interface.
func (i *Identifier) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
addCommonResponseHeaders(rw.Header())
addNoCacheResponseHeaders(rw.Header())
// Show default.
i.writeWebappIndexHTML(rw, req)
}
// SetKey sets the provided key for the accociated identifier.
func (i *Identifier) SetKey(key []byte) error {
var ce jose.ContentEncryption
var algo jose.KeyAlgorithm
switch len(key) {
case 16:
ce = jose.A128GCM
algo = jose.A128GCMKW
case 24:
ce = jose.A192GCM
algo = jose.A192GCMKW
case 32:
ce = jose.A256GCM
algo = jose.A256GCMKW
default:
return fmt.Errorf("identifier invalid encryption key size. Need 16, 24 or 32 bytes")
}
if len(key) < 32 {
i.logger.Warnf("identifier using encryption key size with %d bytes which is below 32 bytes", len(key))
} else {
i.logger.WithField("security", fmt.Sprintf("%s:%s", ce, algo)).Infoln("identifier set up")
}
recipient := jose.Recipient{
Algorithm: algo,
KeyID: "",
Key: key,
}
encrypter, err := jose.NewEncrypter(
ce,
recipient,
nil,
)
if err != nil {
return err
}
i.encrypter = encrypter
i.recipient = &recipient
return nil
}
// ErrorPage writes a HTML error page to the provided ResponseWriter.
func (i *Identifier) ErrorPage(rw http.ResponseWriter, code int, title string, message string) {
utils.WriteErrorPage(rw, code, title, message)
}
// SetUserToLogonCookie serializes the provided user into an encrypted string
// and sets it as cookie on the provided http.ResponseWriter.
func (i *Identifier) SetUserToLogonCookie(ctx context.Context, rw http.ResponseWriter, user *IdentifiedUser) error {
loggedOn, logonAt := user.LoggedOn()
if !loggedOn {
return fmt.Errorf("refused to set cookie for not logged on user")
}
// Add standard claims.
claims := jwt.Claims{
Issuer: user.BackendName(),
Audience: audienceMarker,
Subject: user.Subject(),
IssuedAt: jwt.NewNumericDate(logonAt),
}
// Add expiration, if set.
if user.expiresAfter != nil {
claims.Expiry = jwt.NewNumericDate(*user.expiresAfter)
}
// Additional claims.
userClaims := map[string]interface{}(user.Claims())
if sessionRef := user.SessionRef(); sessionRef != nil {
userClaims[SessionIDClaim] = *sessionRef
}
if logonRef := user.LogonRef(); logonRef != nil {
userClaims[LogonRefClaim] = *logonRef
}
if externalAuthorityID := user.ExternalAuthorityID(); externalAuthorityID != nil {
userClaims[ExternalAuthorityIDClaim] = *externalAuthorityID
}
if lockedScopes := user.LockedScopes(); lockedScopes != nil {
userClaims[LockedScopesClaim] = strings.Join(lockedScopes, " ")
}
// Serialize and encrypt cookie value.
serialized, err := jwt.Encrypted(i.encrypter).Claims(claims).Claims(userClaims).CompactSerialize()
if err != nil {
return err
}
// Set cookie.
err = i.setLogonCookie(rw, serialized)
if err != nil {
return err
}
// Trigger callbacks.
for _, f := range i.onSetLogonCallbacks {
err = f(ctx, rw, user)
if err != nil {
return err
}
}
return nil
}
// UnsetLogonCookie adds cookie remove headers to the provided http.ResponseWriter
// effectively implementing logout.
func (i *Identifier) UnsetLogonCookie(ctx context.Context, user *IdentifiedUser, rw http.ResponseWriter) error {
// Remove cookie.
err := i.removeLogonCookie(rw)
if err != nil {
return err
}
// Destroy backend user session if any.
if user != nil {
if sessionRef := user.SessionRef(); sessionRef != nil {
err = i.backend.DestroySession(ctx, sessionRef)
if err != nil {
i.logger.WithError(err).Warnln("failed to destroy session on unset logon cookie")
}
}
}
// Trigger callbacks.
for _, f := range i.onUnsetLogonCallbacks {
err = f(ctx, rw)
if err != nil {
return err
}
}
return nil
}
// EndSession begins the process to end the session either directly or indirectly
// based on the provided user. It optionally returns an uri which shall be used
// as redirection target or an error.
func (i *Identifier) EndSession(ctx context.Context, user *IdentifiedUser, rw http.ResponseWriter, postRedirectURI *url.URL, state string) (*url.URL, error) {
err := i.UnsetLogonCookie(ctx, user, rw)
if err != nil {
return nil, err
}
var uri *url.URL
if user.externalAuthority != nil && user.externalAuthority.EndSessionEnabled {
// Generate state and set state cookie with postRedirectURI.
if state == "" {
state = rndm.GenerateRandomString(32)
}
sd := &StateData{
State: state,
Mode: StateModeEndSession,
Ref: user.externalAuthority.ID,
}
var extra map[string]interface{}
uri, extra, err = user.externalAuthority.MakeRedirectEndSessionRequestURL(user.LogonRef(), sd.State)
if err != nil {
return nil, err
}
sd.Extra = extra
if postRedirectURI != nil && postRedirectURI.String() != "" {
sd.RawQuery = postRedirectURI.String()
}
var scope string
switch user.externalAuthority.AuthorityType {
case authorities.AuthorityTypeOIDC:
// Inject post logout url target.
cb, _ := i.router.GetRoute("oauth2/cb").URL()
next, _ := url.Parse(i.baseURI.String())
next.Path = cb.Path
query := uri.Query()
query.Set("post_logout_redirect_uri", next.String())
uri.RawQuery = query.Encode()
// Redirect using trampolin, to ensure origin checks of external
// authority can pass.
sd.Trampolin = &TrampolinData{
URI: uri.String(),
Scope: "oauth2/cb",
}
scope = "trampolin"
uri, _ = i.router.GetRoute("trampolin").URL()
query = make(url.Values)
query.Add("state", sd.State)
uri.RawQuery = query.Encode()
case authorities.AuthorityTypeSAML2:
scope = "_/saml2/slo"
}
if scope != "" {
err = i.SetStateToStateCookie(ctx, rw, scope, sd)
if err != nil {
return nil, fmt.Errorf("failed to set saml2 slo state cookie: %w", err)
}
}
}
return uri, nil
}
// GetUserFromLogonCookie looks up the associated cookie name from the provided
// request, parses it and returns the user containing the information found in
// the coookie payload data.
func (i *Identifier) GetUserFromLogonCookie(ctx context.Context, req *http.Request, maxAge time.Duration, refreshSession bool) (*IdentifiedUser, error) {
cookie, err := i.getLogonCookie(req)
if err != nil {
if err == http.ErrNoCookie {
return nil, nil
}
return nil, err
}
// Decrypt and parse cookie value.
token, err := jwt.ParseEncrypted(cookie.Value)
if err != nil {
return nil, err
}
// Parse claims.
var claims jwt.Claims
var userClaims map[string]interface{}
if claimsErr := token.Claims(i.recipient.Key, &claims, &userClaims); claimsErr != nil {
return nil, claimsErr
}
// Validate claims.
if claimsErr := claims.Validate(jwt.Expected{
// Ignore cookie, when issuer does not match our backend name. This usually
// means that konnect was reconfigured. Users need to sign in again.
Issuer: i.backend.Name(),
// Ignore cookie, when audience marker does not match. This happens
// for cookies from an older version of konnect. Users need to sign in again.
Audience: jwt.Audience{audienceMarker[0]},
}); claimsErr != nil {
i.logger.WithError(claimsErr).Debugln("logon token claims validation failed")
return nil, nil
}
if claims.Subject == "" {
return nil, fmt.Errorf("invalid subject in logon token")
}
if userClaims == nil {
return nil, fmt.Errorf("invalid user claims in logon token")
}
// New user with details from claims.
user := &IdentifiedUser{
sub: claims.Subject,
// TODO(longsleep): It is not verified here that the user still exists at
// our current backend. We still assign the backend happily here - probably
// needs some sort of veritification / lookup.
backend: i.backend,
logonAt: claims.IssuedAt.Time(),
}
if claims.Expiry != nil {
expiresAfter := claims.Expiry.Time()
user.expiresAfter = &expiresAfter
}
loggedOn, logonAt := user.LoggedOn()
if !loggedOn {
// Ignore logons which are not valid.
return nil, nil
}
if maxAge > 0 {
if logonAt.Add(maxAge).Before(time.Now()) {
// Ignore logon as it is no longer valid within maxAge.
return nil, nil
}
}
// Get specific data from claims.
if v := userClaims[SessionIDClaim]; v != nil {
sessionRef := v.(string)
if sessionRef != "" {
// Remember session ref in user.
user.sessionRef = &sessionRef
// Ensure the session is still valid, by refreshing it.
if refreshSession {
err = i.backend.RefreshSession(ctx, user.Subject(), &sessionRef, userClaims)
if err != nil {
// Ignore logons which fail session refresh.
return nil, nil
}
}
}
}
if v := userClaims[LogonRefClaim]; v != nil {
logonRef := v.(string)
if logonRef != "" {
// Remember logon ref in user.
user.logonRef = &logonRef
}
}
if v := userClaims[ExternalAuthorityIDClaim]; v != nil {
externalAuthorityID := v.(string)
if externalAuthorityID != "" {
authority, err := i.authorities.Lookup(ctx, externalAuthorityID)
if err != nil {
// Ignore logons which have set an unknown external authority.
return nil, nil
}
// TODO(longsleep): Check if authority is actually enabled. For now
// we check if it is ready.
if !authority.IsReady() {
// Ignore logons which have sent an authority which is not ready.
return nil, nil
}
user.externalAuthority = authority
}
}
if v := userClaims[LockedScopesClaim]; v != nil {
lockedScopes := v.(string)
if lockedScopes != "" {
user.lockedScopes = strings.Split(lockedScopes, " ")
}
}
// Fill additional claim.
user.claims = make(map[string]interface{})
for k, v := range userClaims {
switch k {
case konnect.IdentifiedUsernameClaim:
user.username = v.(string)
case konnect.IdentifiedDisplayNameClaim:
user.displayName = v.(string)
case SessionIDClaim:
// Already handled above.
continue
case LogonRefClaim:
// Already handled above.
continue
case ExternalAuthorityIDClaim:
// Already handled above.
continue
case LockedScopesClaim:
// Already handled above.
continue
case ObsoleteUserClaimsClaim:
// Keep and ignore for history reasons.
continue
case oidc.AudienceClaim, oidc.IssuedAtClaim, oidc.ExpirationClaim, oidc.SubjectIdentifierClaim, oidc.IssuerIdentifierClaim:
// Ignore default OIDC claims when resurrecting claims data.
continue
default:
// Add the rest.
user.claims[k] = v
}
}
return user, nil
}
// GetUserFromID looks up the user identified by the provided userID by
// requesting the associated backend.
func (i *Identifier) GetUserFromID(ctx context.Context, userID string, sessionRef *string, requestedScopes map[string]bool) (*IdentifiedUser, error) {
user, err := i.backend.GetUser(ctx, userID, sessionRef, requestedScopes)
if err != nil {
return nil, err
}
if user == nil {
return nil, nil
}
// XXX(longsleep): This is quite crappy. Move IdentifiedUser to a package
// which can be imported by backends so they directly can return that shit.
identifiedUser := &IdentifiedUser{
sub: user.Subject(),
username: user.Username(),
backend: i.backend,
sessionRef: sessionRef,
claims: user.BackendClaims(),
scopes: user.BackendScopes(),
lockedScopes: user.RequiredScopes(),
}
if userWithEmail, ok := user.(identity.UserWithEmail); ok {
identifiedUser.email = userWithEmail.Email()
identifiedUser.emailVerified = userWithEmail.EmailVerified()
}
if userWithProfile, ok := user.(identity.UserWithProfile); ok {
identifiedUser.displayName = userWithProfile.Name()
identifiedUser.familyName = userWithProfile.FamilyName()
identifiedUser.givenName = userWithProfile.GivenName()
}
if userWithID, ok := user.(identity.UserWithID); ok {
identifiedUser.id = userWithID.ID()
}
if userWithUniqueID, ok := user.(identity.UserWithUniqueID); ok {
identifiedUser.uid = userWithUniqueID.UniqueID()
}
return identifiedUser, nil
}
// SetConsentToConsentCookie serializses the provided Consent using the provided
// ConsentRequest and sets it as cookie on the provided ReponseWriter.
func (i *Identifier) SetConsentToConsentCookie(ctx context.Context, rw http.ResponseWriter, cr *ConsentRequest, consent *Consent) error {
serialized, err := jwt.Encrypted(i.encrypter).Claims(consent).CompactSerialize()
if err != nil {
return err
}
return i.setConsentCookie(rw, cr, serialized)
}
// GetConsentFromConsentCookie extract consent information for the provided
// request and the provide state.
func (i *Identifier) GetConsentFromConsentCookie(ctx context.Context, rw http.ResponseWriter, req *http.Request, state string) (*Consent, error) {
if state == "" {
return nil, nil
}
cr := &ConsentRequest{
State: state,
ClientID: req.Form.Get("client_id"),
RawRedirectURI: req.Form.Get("redirect_uri"),
Ref: req.Form.Get("state"),
Nonce: req.Form.Get("nonce"),
}
cookie, err := i.getConsentCookie(req, cr)
if err != nil {
if err == http.ErrNoCookie {
return nil, nil
}
return nil, err
}
// Directly remove the cookie again after we used it.
i.removeConsentCookie(rw, req, cr)
token, err := jwt.ParseEncrypted(cookie.Value)
if err != nil {
return nil, err
}
var consent Consent
if err = token.Claims(i.recipient.Key, &consent); err != nil {
return nil, err
}
return &consent, nil
}
// SetStateToStateCookie serializses the provided StateRequest and sets it
// as cookie on the provided ReponseWriter.
func (i *Identifier) SetStateToStateCookie(ctx context.Context, rw http.ResponseWriter, scope string, sd *StateData) error {
serialized, err := jwt.Encrypted(i.encrypter).Claims(sd).CompactSerialize()
if err != nil {
return err
}
return i.setStateCookie(rw, scope, sd.State, serialized)
}
// GetStateFromStateCookie extracts state information for the provided
// request using the provided scope and state.
func (i *Identifier) GetStateFromStateCookie(ctx context.Context, rw http.ResponseWriter, req *http.Request, scope string, state string) (*StateData, error) {
if state == "" {
return nil, nil
}
cookie, err := i.getStateCookie(req, state)
if err != nil {
if err == http.ErrNoCookie {
return nil, nil
}
return nil, err
}
// Directly remove the cookie again after we used it.
i.removeStateCookie(rw, req, scope, state)
token, err := jwt.ParseEncrypted(cookie.Value)
if err != nil {
return nil, err
}
sd := &StateData{}
if err = token.Claims(i.recipient.Key, sd); err != nil {
return nil, err
}
if sd.State != state {
return nil, fmt.Errorf("state mismatch")
}
return sd, nil
}
// Name returns the active identifiers backend's name.
func (i *Identifier) Name() string {
return i.backend.Name()
}
// ScopesSupported return the scopes supported by the accociated Identifier.
func (i *Identifier) ScopesSupported() []string {
scopes := mapset.NewThreadUnsafeSet()
for scope := range i.meta.Scopes.Definitions {
scopes.Add(scope)
}
for _, scope := range i.backend.ScopesSupported() {
scopes.Add(scope)
}
supportedScopes := make([]string, 0)
it := scopes.Iterator()
for scope := range it.C {
supportedScopes = append(supportedScopes, scope.(string))
}
return supportedScopes
}
// OnSetLogon implements a way to register hooks whenever logon information is
// set by the accociated Identifier.
func (i *Identifier) OnSetLogon(cb func(ctx context.Context, rw http.ResponseWriter, user identity.User) error) error {
i.onSetLogonCallbacks = append(i.onSetLogonCallbacks, cb)
return nil
}
// OnUnsetLogon implements a way to register hooks whenever logon information is
// set by the accociated Identifier.
func (i *Identifier) OnUnsetLogon(cb func(ctx context.Context, rw http.ResponseWriter) error) error {
i.onUnsetLogonCallbacks = append(i.onUnsetLogonCallbacks, cb)
return nil
}
func (i *Identifier) absoluteURLForRoute(name string) (*url.URL, error) {
uri, _ := url.Parse(i.Config.BaseURI.String())
route := i.router.Get(name)
path, err := route.URL()
if err != nil {
return nil, err
}
uri.Path = path.Path
return uri, nil
}
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head data-kopano-build="%VITE_KOPANO_BUILD%">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#ffffff">
<link rel="shortcut icon" href="/static/favicon.ico" type="image/x-icon">
<meta property="csp-nonce" content="__CSP_NONCE__">
<title>Sign in to your account</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="bg">
<div></div>
</div>
<div id="root" data-path-prefix="__PATH_PREFIX__"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
/*
* Copyright 2021 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package meta
// Branding is a container to hold identifier branding meta data.
type Branding struct {
BannerLogo *string `json:"bannerLogo,omitempty"`
SignInPageText *string `json:"signinPageText,omitempty"`
UsernameHintText *string `json:"usernameHintText,omitempty"`
SignInPageLogoURI *string `json:"signinPageLogoURI,omitempty"`
Locales []string `json:"locales,omitempty"`
}
+28
View File
@@ -0,0 +1,28 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package meta
import (
"github.com/libregraph/lico/identifier/meta/scopes"
)
// Meta is a container to hold identifier meta data which can be requested by
// clients.
type Meta struct {
Scopes *scopes.Scopes `json:"scopes"`
}
@@ -0,0 +1,25 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package scopes
// A Definition contains the meta data for a single scope.
type Definition struct {
Priority int `json:"priority" yaml:"priority"`
Description string `json:"description,omitempty" yaml:"description"`
ID string `json:"id,omitempty"`
}
@@ -0,0 +1,161 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package scopes
import (
"io/ioutil"
"github.com/libregraph/oidc-go"
"github.com/sirupsen/logrus"
"gopkg.in/yaml.v2"
konnect "github.com/libregraph/lico"
)
const (
scopeAliasBasic = "basic"
scopeUnknown = "unknown"
)
const (
priorityBasic = 20
priorityOfflineAccess = 10
)
var defaultScopesMap = map[string]string{
oidc.ScopeOpenID: scopeAliasBasic,
oidc.ScopeEmail: scopeAliasBasic,
oidc.ScopeProfile: scopeAliasBasic,
konnect.ScopeNumericID: scopeAliasBasic,
konnect.ScopeUniqueUserID: scopeAliasBasic,
konnect.ScopeRawSubject: scopeAliasBasic,
}
var defaultScopesDefinitionMap = map[string]*Definition{
scopeAliasBasic: &Definition{
ID: "scope_alias_basic",
Priority: priorityBasic,
},
oidc.ScopeOfflineAccess: &Definition{
ID: "scope_offline_access",
Priority: priorityOfflineAccess,
},
}
// Scopes contain collections for scope related meta data
type Scopes struct {
Mapping map[string]string `json:"mapping" yaml:"mapping"`
Definitions map[string]*Definition `json:"definitions" yaml:"scopes"`
}
// NewScopesFromIDs creates a new scopes meta data collection from the provided
// scopes IDs optionally also adding definitions from a parent.
func NewScopesFromIDs(scopes map[string]bool, parent *Scopes) *Scopes {
mapping := make(map[string]string)
definitions := make(map[string]*Definition)
for scope, enabled := range scopes {
if !enabled {
continue
}
alias := scope
if mapped, ok := parent.Mapping[scope]; ok {
alias = mapped
mapping[scope] = mapped
} else if mapped, ok := defaultScopesMap[scope]; ok {
alias = mapped
mapping[scope] = mapped
}
if definition, ok := parent.Definitions[alias]; ok {
definitions[alias] = definition
} else if definition, ok := defaultScopesDefinitionMap[alias]; ok {
definitions[alias] = definition
}
}
return &Scopes{
Mapping: mapping,
Definitions: definitions,
}
}
// NewScopesFromFile loads scope definitions from a file.
func NewScopesFromFile(scopesConfFilepath string, logger logrus.FieldLogger) (*Scopes, error) {
scopes := &Scopes{}
if scopesConfFilepath != "" {
logger.Debugf("parsing scopes conf from %v", scopesConfFilepath)
confFile, err := ioutil.ReadFile(scopesConfFilepath)
if err != nil {
return nil, err
}
err = yaml.Unmarshal(confFile, scopes)
if err != nil {
return nil, err
}
for id, definition := range scopes.Definitions {
fields := logrus.Fields{
"id": id,
"priority": definition.Priority,
}
logger.WithFields(fields).Debugln("registered scope")
}
for id, mapped := range scopes.Mapping {
fields := logrus.Fields{
"id": id,
"to": mapped,
}
logger.WithFields(fields).Debugln("registered scope mapping")
}
}
if scopes.Mapping == nil {
scopes.Mapping = make(map[string]string)
}
if scopes.Definitions == nil {
scopes.Definitions = make(map[string]*Definition)
}
return scopes, nil
}
// Extend adds the provided scope mappings and definitions to the accociated
// scopes mappings and definitions with replacing already existing. If scopes is
// nil, Extends is a no-op.
func (s *Scopes) Extend(scopes *Scopes) error {
if scopes == nil {
return nil
}
for scope, definition := range scopes.Definitions {
s.Definitions[scope] = definition
}
for mapped, mapping := range scopes.Mapping {
s.Mapping[mapped] = mapping
}
return nil
}
+178
View File
@@ -0,0 +1,178 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package identifier
import (
"net/url"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/libregraph/lico/identifier/meta"
"github.com/libregraph/lico/identity/clients"
)
// A LogonRequest is the request data as sent to the logon endpoint
type LogonRequest struct {
State string `json:"state"`
Params []string `json:"params"`
Hello *HelloRequest `json:"hello"`
}
// A LogonResponse holds a response as sent by the logon endpoint.
type LogonResponse struct {
Success bool `json:"success"`
State string `json:"state"`
Hello *HelloResponse `json:"hello"`
}
// A HelloRequest is the request data as send to the hello endpoint.
type HelloRequest struct {
State string `json:"state"`
Flow string `json:"flow"`
RawScope string `json:"scope"`
RawPrompt string `json:"prompt"`
ClientID string `json:"client_id"`
RawRedirectURI string `json:"redirect_uri"`
RawIDTokenHint string `json:"id_token_hint"`
RawMaxAge string `json:"max_age"`
Scopes map[string]bool `json:"-"`
Prompts map[string]bool `json:"-"`
RedirectURI *url.URL `json:"-"`
IDTokenHint *jwt.Token `json:"-"`
MaxAge time.Duration `json:"-"`
//TODO(longsleep): Add support to pass request parameters as JWT as
// specified in http://openid.net/specs/openid-connect-core-1_0.html#JWTRequests
}
func (hr *HelloRequest) parse() error {
hr.Scopes = make(map[string]bool)
hr.Prompts = make(map[string]bool)
hr.RedirectURI, _ = url.Parse(hr.RawRedirectURI)
if hr.RawScope != "" {
for _, scope := range strings.Split(hr.RawScope, " ") {
hr.Scopes[scope] = true
}
}
if hr.RawPrompt != "" {
for _, prompt := range strings.Split(hr.RawPrompt, " ") {
hr.Prompts[prompt] = true
}
}
if hr.RawMaxAge != "" {
maxAgeInt, err := strconv.ParseInt(hr.RawMaxAge, 10, 64)
if err != nil {
return err
}
hr.MaxAge = time.Duration(maxAgeInt) * time.Second
}
return nil
}
// A HelloResponse holds a response as sent by the hello endpoint.
type HelloResponse struct {
State string `json:"state"`
Flow string `json:"flow"`
Success bool `json:"success"`
Username string `json:"username,omitempty"`
DisplayName string `json:"displayName,omitempty"`
Next string `json:"next,omitempty"`
ContinueURI string `json:"continue_uri,omitempty"`
Scopes map[string]bool `json:"scopes,omitempty"`
ClientDetails *clients.Details `json:"client,omitempty"`
Meta *meta.Meta `json:"meta,omitempty"`
Branding *meta.Branding `json:"branding,omitempty"`
}
// A StateRequest is a general request with a state.
type StateRequest struct {
State string
}
// A StateResponse hilds a response as reply to a StateRequest.
type StateResponse struct {
Success bool `json:"success"`
State string `json:"state"`
}
// StateData contains data bound to a state.
type StateData struct {
State string `json:"state"`
Mode string `json:"mode,omitempty"`
RawQuery string `json:"raw_query,omitempty"`
ClientID string `json:"client_id"`
Ref string `json:"ref,omitempty"`
Extra map[string]interface{} `json:"extra,omitempty"`
Trampolin *TrampolinData `json:"trampolin,omitempty"`
}
type TrampolinData struct {
URI string `json:"uri"`
Scope string `json:"scope"`
}
// A ConsentRequest is the request data as sent to the consent endpoint.
type ConsentRequest struct {
State string `json:"state"`
Allow bool `json:"allow"`
RawScope string `json:"scope"`
ClientID string `json:"client_id"`
RawRedirectURI string `json:"redirect_uri"`
Ref string `json:"ref"`
Nonce string `json:"flow_nonce"`
}
// Consent is the data received and sent to allow or cancel consent flows.
type Consent struct {
Allow bool `json:"allow"`
RawScope string `json:"scope"`
}
// Scopes returns the associated consents approved scopes filtered by the
//provided requested scopes and the full unfiltered approved scopes table.
func (c *Consent) Scopes(requestedScopes map[string]bool) (map[string]bool, map[string]bool) {
scopes := make(map[string]bool)
if c.RawScope != "" {
for _, scope := range strings.Split(c.RawScope, " ") {
scopes[scope] = true
}
}
approved := make(map[string]bool)
for n, v := range requestedScopes {
if ok, _ := scopes[n]; ok && v {
approved[n] = true
}
}
return approved, scopes
}
+41
View File
@@ -0,0 +1,41 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package identifier
const (
// ModeLogonUsernameEmptyPasswordCookie is the logon mode which requires a
// username which matches the currently signed in user in the cookie and an
// empty password.
ModeLogonUsernameEmptyPasswordCookie = "0"
// ModeLogonUsernamePassword is the logon mode which requires a username
// and a password.
ModeLogonUsernamePassword = "1"
)
const (
// MustBeSignedIn is a authorize mode which tells the authorization code,
// that it is expected to have a signed in user and everything else should
// be treated as error.
MustBeSignedIn = "must"
)
const (
// StateModeEndSession is a state mode which selects end session specific
// actions when processing state requests.
StateModeEndSession = "0"
)
+396
View File
@@ -0,0 +1,396 @@
/*
* Copyright 2017-2020 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package identifier
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/libregraph/oidc-go"
"github.com/longsleep/rndm"
"golang.org/x/oauth2"
"github.com/libregraph/lico/identity/authorities"
konnectoidc "github.com/libregraph/lico/oidc"
"github.com/libregraph/lico/oidc/payload"
"github.com/libregraph/lico/utils"
)
func (i *Identifier) writeOAuth2Start(rw http.ResponseWriter, req *http.Request, authority *authorities.Details) {
var err error
if authority == nil {
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2TemporarilyUnavailable, "no authority")
} else if !authority.IsReady() {
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2TemporarilyUnavailable, "authority not ready")
}
switch typedErr := err.(type) {
case nil:
// breaks
case *konnectoidc.OAuth2Error:
// Redirect back, with error.
i.logger.WithFields(utils.ErrorAsFields(err)).Debugln("oauth2 start error")
// NOTE(longsleep): Pass along error ID but not the description to avoid
// leaking potentially internal information to our RP.
uri, _ := url.Parse(i.authorizationEndpointURI.String())
query, _ := url.ParseQuery(req.URL.RawQuery)
query.Del("flow")
query.Set("error", typedErr.ErrorID)
query.Set("error_description", "identifier failed to authenticate")
uri.RawQuery = query.Encode()
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
return
default:
i.logger.WithError(err).Errorln("identifier failed to process oauth2 start")
i.ErrorPage(rw, http.StatusInternalServerError, "", "oauth2 start failed")
return
}
sd := &StateData{
State: rndm.GenerateRandomString(32),
RawQuery: req.URL.RawQuery,
ClientID: authority.ClientID,
Ref: authority.ID,
}
// Construct URL to redirect client to external OAuth2 authorize endpoints.
uri, extra, err := authority.MakeRedirectAuthenticationRequestURL(sd.State)
if err != nil {
i.logger.WithError(err).Errorln("identifier failed to create authentication request: %w", err)
i.ErrorPage(rw, http.StatusInternalServerError, "", "oauth2 start failed")
return
}
if extra != nil {
sd.Extra = extra
} else {
sd.Extra = make(map[string]interface{})
}
query := uri.Query()
query.Add("client_id", authority.ClientID)
if authority.ResponseType != "" {
query.Add("response_type", authority.ResponseType)
}
if authority.ResponseMode != "" {
query.Add("response_mode", authority.ResponseMode)
}
query.Add("scope", strings.Join(authority.Scopes, " "))
query.Add("redirect_uri", i.oauth2CbEndpointURI.String())
query.Add("nonce", rndm.GenerateRandomString(32))
if authority.CodeChallengeMethod != "" {
codeVerifier := rndm.GenerateRandomString(32)
sd.Extra["code_verifier"] = codeVerifier
codeChallenge := ""
if codeChallenge, err = oidc.MakeCodeChallenge(authority.CodeChallengeMethod, codeVerifier); err == nil {
query.Add("code_challenge", codeChallenge)
query.Add("code_challenge_method", authority.CodeChallengeMethod)
} else {
i.logger.WithError(err).Debugln("identifier failed to create oauth2 code challenge")
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to create code challenge")
return
}
}
if display := req.Form.Get("display"); display != "" {
query.Add("display", display)
}
if prompt := req.Form.Get("prompt"); prompt != "" && prompt != oidc.PromptConsent {
// Pass along all prompt values, except consent to external provider and
// handle consent as needed ourselves.
query.Add("prompt", prompt)
}
if maxAge := req.Form.Get("max_age"); maxAge != "" {
query.Add("max_age", maxAge)
}
if uiLocales := req.Form.Get("ui_locales"); uiLocales != "" {
query.Add("ui_locales", uiLocales)
}
if acrValues := req.Form.Get("acr_values"); acrValues != "" {
query.Add("acr_values", acrValues)
}
if claimsLocales := req.Form.Get("claims_locales"); claimsLocales != "" {
query.Add("claims_locales", claimsLocales)
}
// Set cookie which is consumed by the callback later.
err = i.SetStateToStateCookie(req.Context(), rw, "oauth2/cb", sd)
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to set oauth2 state cookie")
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to set cookie")
return
}
uri.RawQuery = query.Encode()
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
}
func (i *Identifier) writeOAuth2Cb(rw http.ResponseWriter, req *http.Request) {
// Callbacks from authorization or end session. Validate as specified at
// https://tools.ietf.org/html/rfc6749#section-4.1.2 and https://tools.ietf.org/html/rfc6749#section-10.12.
var err error
var sd *StateData
var user *IdentifiedUser
var userInfoClaims jwt.MapClaims
var authority *authorities.Details
for {
sd, err = i.GetStateFromStateCookie(req.Context(), rw, req, "oauth2/cb", req.Form.Get("state"))
if err != nil {
err = fmt.Errorf("failed to decode oauth2 cb state: %w", err)
break
}
if sd == nil {
err = errors.New("state not found")
break
}
// Load authority with client_id in state.
authority, _ = i.authorities.Lookup(req.Context(), sd.Ref)
if authority == nil {
i.logger.WithField("client_id", sd.ClientID).Debugln("identifier failed to find authority in oauth2 cb")
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2InvalidRequest, "unknown client_id")
break
}
if authority.AuthorityType != authorities.AuthorityTypeOIDC {
err = errors.New("unknown authority type")
break
}
// Check incoming state type.
var done bool
done, err = func() (bool, error) {
switch sd.Mode {
case StateModeEndSession:
// Special mode. When in end session, take value from state and
// redirect to it. This completes end session callback.
uri, _ := url.Parse(sd.RawQuery)
if uri == nil {
return false, konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2InvalidRequest, "no uri in state")
}
if sd.State != "" {
query := uri.Query()
query.Set("state", sd.State)
uri.RawQuery = query.Encode()
}
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
return true, nil
default:
// Continue further.
}
return false, nil
}()
if err != nil {
break
}
if done {
// Already done, nothing further so return.
return
}
if authority.ResponseType == oidc.ResponseTypeCode ||
authority.ResponseType == oidc.ResponseTypeCodeIDToken ||
authority.ResponseType == oidc.ResponseTypeCodeIDTokenToken {
// Exchange code for ID token.
md := authority.Metadata().(*oidc.WellKnown)
config := &oauth2.Config{
ClientID: authority.ClientID,
ClientSecret: authority.ClientSecret,
RedirectURL: i.oauth2CbEndpointURI.String(),
Endpoint: oauth2.Endpoint{
TokenURL: md.TokenEndpoint,
},
Scopes: authority.Scopes,
}
var httpClient *http.Client
if authority.Insecure {
httpClient = utils.InsecureHTTPClient
} else {
httpClient = utils.DefaultHTTPClient
}
t, exchangeErr := config.Exchange(
context.WithValue(req.Context(), oauth2.HTTPClient, httpClient),
req.Form.Get("code"),
oauth2.SetAuthURLParam("code_verifier",
sd.Extra["code_verifier"].(string)),
)
if exchangeErr != nil {
err = fmt.Errorf("failed to exchange code for token: %w", exchangeErr)
break
}
// Inject found data into request for later parse.
req.Form.Set("access_token", t.AccessToken)
req.Form.Set("token_type", t.TokenType)
req.Form.Set("refresh_token", t.RefreshToken)
if v, ok := t.Extra("expires_in").(string); ok {
req.Form.Set("expires_in", v)
}
if v, ok := t.Extra("id_token").(string); ok {
req.Form.Set("id_token", v)
}
// Fetch userinfo.
uiReq, requestErr := http.NewRequest(http.MethodGet, md.UserInfoEndpoint, http.NoBody)
if requestErr != nil {
err = fmt.Errorf("failed to create userinfo request: %w", requestErr)
break
}
t.SetAuthHeader(uiReq)
uiResp, responseErr := httpClient.Do(uiReq)
if responseErr != nil {
err = fmt.Errorf("failed to get userinfo: %w", responseErr)
break
}
// Decode userinfo as JSON, directly into the claims set.
if decodeErr := json.NewDecoder(uiResp.Body).Decode(&userInfoClaims); decodeErr != nil {
err = fmt.Errorf("failed to decode userinfo response: %w", decodeErr)
uiResp.Body.Close()
break
}
uiResp.Body.Close()
}
// Parse incoming state response.
var authenticationSuccess *payload.AuthenticationSuccess
if authenticationSuccessRaw, parseErr := authority.ParseStateResponse(req, sd.State, sd.Extra); parseErr == nil {
authenticationSuccess = authenticationSuccessRaw.(*payload.AuthenticationSuccess)
} else {
err = parseErr
break
}
// Parse and validate IDToken.
idToken, idTokenParseErr := jwt.ParseWithClaims(authenticationSuccess.IDToken, userInfoClaims, authority.JWTKeyfunc())
if idTokenParseErr != nil {
if authority.Insecure {
i.logger.WithField("client_id", sd.ClientID).WithError(idTokenParseErr).Warnln("identifier ignoring validation error for insecure authority")
} else {
i.logger.WithError(idTokenParseErr).Debugln("identifier failed to validate oauth2 cb id token")
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2ServerError, "authority response validation failed")
break
}
}
if claims, _ := idToken.Claims.(jwt.MapClaims); claims == nil {
err = errors.New("invalid id token claims")
break
}
// Lookup username and user.
un, extra, claimsErr := authority.IdentityClaimValue(idToken)
if claimsErr != nil {
i.logger.WithError(claimsErr).Debugln("identifier failed to get username from oauth2 cb id token claims")
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2InsufficientScope, "identity claim not found")
break
}
username := &un
// TODO(longsleep): This flow currently does not provide a hello
// context, means that downwards a backend might fail to resolve the
// user when it requires additional information for multiple backend
// routing.
user, err = i.resolveUser(req.Context(), *username)
if err != nil {
i.logger.WithError(err).WithField("username", *username).Debugln("identifier failed to resolve oauth2 cb user with backend")
// TODO(longsleep): Break on validation error.
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2AccessDenied, "failed to resolve user")
break
}
if user == nil || user.Subject() == "" {
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2AccessDenied, "no such user")
break
}
var logonRef string
if rawIDToken, ok := extra["RawIDToken"]; ok {
logonRef = rawIDToken.(string)
}
if logonRef != "" {
user.logonRef = &logonRef
}
// Get user meta data.
// TODO(longsleep): This is an additional request to the backend. This
// should be avoided. Best would be if the backend would return everything
// in one shot (TODO in core).
err = i.updateUser(req.Context(), user, authority)
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to update user data in oauth2 cb request")
}
// Set logon time.
user.logonAt = time.Now()
err = i.SetUserToLogonCookie(req.Context(), rw, user)
if err != nil {
i.logger.WithError(err).Errorln("identifier failed to serialize logon ticket in oauth2 cb")
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to serialize logon ticket")
return
}
break
}
if sd == nil {
i.logger.WithError(err).Debugln("identifier oauth2 cb without state")
i.ErrorPage(rw, http.StatusBadRequest, "", "state not found")
return
}
uri, _ := url.Parse(i.authorizationEndpointURI.String())
query, _ := url.ParseQuery(sd.RawQuery)
query.Del("flow")
query.Set("identifier", MustBeSignedIn)
if query.Get("prompt") == oidc.PromptSelectAccount {
// Remove select_acount prompt for our secondary indentifier, it was
// already processed by the external provider.
query.Del("prompt")
}
switch typedErr := err.(type) {
case nil:
// breaks
case *konnectoidc.OAuth2Error:
// Pass along OAuth2 error.
i.logger.WithFields(utils.ErrorAsFields(err)).Debugln("oauth2 cb error")
// NOTE(longsleep): Pass along error ID but not the description to avoid
// leaking potetially internal information to our RP.
query.Set("error", typedErr.ErrorID)
query.Set("error_description", "identifier failed to authenticate")
//breaks
default:
i.logger.WithError(err).Errorln("identifier failed to process oauth2 cb")
i.ErrorPage(rw, http.StatusInternalServerError, "", "oauth2 cb failed")
return
}
uri.RawQuery = query.Encode()
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
}
+121
View File
@@ -0,0 +1,121 @@
{
"name": "identifier",
"version": "1.0.0",
"private": true,
"homepage": ".",
"dependencies": {
"@fontsource/roboto": "^4.5.8",
"@material-ui/core": "^4.12.4",
"@material-ui/icons": "^4.11.3",
"axios": "^0.22.0",
"classnames": "^2.3.2",
"glob": "^8.1.0",
"i18next": "^21.10.0",
"i18next-browser-languagedetector": "^6.1.8",
"i18next-http-backend": "^1.4.5",
"i18next-resources-to-backend": "^1.0.0",
"query-string": "^7.1.3",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-i18next": "^11.18.6",
"react-redux": "^7.2.9",
"react-router": "^5.3.4",
"react-router-dom": "5.3.4",
"redux": "^4.2.1",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.4.2",
"render-if": "^0.1.1",
"validator": "^13.12.0",
"web-vitals": "^1.1.2"
},
"scripts": {
"start": "vite",
"build": "vite build",
"serve": "vite preview",
"test": "vitest",
"lint": "eslint --max-warnings=0 src/**/*.{ts,tsx,js,jsx}",
"licenses": "node ../scripts/js-license-ranger.mjs",
"analyze": "source-map-explorer 'build/static/assets/*.js'"
},
"devDependencies": {
"@testing-library/dom": "^8.20.1",
"@testing-library/jest-dom": "^6.1.4",
"@testing-library/react": "^12.1.5",
"@testing-library/user-event": "^12.8.3",
"@types/jest": "^29.5.8",
"@types/node": "^20.9.0",
"@types/react": "^17.0.70",
"@types/react-dom": "^17.0.23",
"@types/react-redux": "^7.1.25",
"@types/redux-logger": "^3.0.12",
"@types/validator": "^13",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.9.0",
"@typescript-eslint/typescript-estree": "^6.11.0",
"@vitejs/plugin-legacy": "^4.0.0",
"@vitejs/plugin-react": "^4.1.1",
"cldr": "^7.4.0",
"eslint": "^8.53.0",
"eslint-config-react-app-bump": "^1.0.16",
"eslint-plugin-i18next": "^5.2.1",
"i18next-conv": "^12.1.1",
"i18next-parser": "^5.4.0",
"if-node-version": "^1.1.1",
"jsdom": "^22.1.0",
"source-map-explorer": "^2.5.3",
"terser": "^5.30.4",
"typescript": "^5.2.2",
"vite": "^4.5.13",
"vite-plugin-checker": "^0.6.2",
"vite-plugin-eslint": "^1.8.1",
"vitest": "^0.34.6"
},
"jest": {
"collectCoverageFrom": [
"src/**/*.{js,jsx,ts,tsx}"
]
},
"eslintConfig": {
"plugins": [
"i18next"
],
"extends": [
"react-app-bump",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:i18next/recommended"
],
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error"
],
"no-use-before-define": "off",
"@typescript-eslint/no-use-before-define": [
"error"
],
"i18next/no-literal-string": [
"off",
{
"markupOnly": true
}
],
"react/prop-types": [
"warn"
]
}
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"packageManager": "yarn@4.0.2"
}
+464
View File
@@ -0,0 +1,464 @@
/*
* Copyright 2017-2020 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package identifier
import (
"errors"
"fmt"
"net/http"
"net/url"
"time"
"github.com/crewjam/saml"
"github.com/libregraph/oidc-go"
"github.com/longsleep/rndm"
"github.com/sirupsen/logrus"
"github.com/libregraph/lico/identity/authorities"
konnectoidc "github.com/libregraph/lico/oidc"
"github.com/libregraph/lico/identity/authorities/samlext"
"github.com/libregraph/lico/utils"
)
func (i *Identifier) writeSAML2Start(rw http.ResponseWriter, req *http.Request, authority *authorities.Details) {
var err error
if authority == nil {
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2TemporarilyUnavailable, "no authority")
} else if !authority.IsReady() {
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2TemporarilyUnavailable, "authority not ready")
}
switch typedErr := err.(type) {
case nil:
// breaks
case *konnectoidc.OAuth2Error:
// Redirect back, with error.
i.logger.WithFields(utils.ErrorAsFields(err)).Debugln("saml2 start error")
// NOTE(longsleep): Pass along error ID but not the description to avoid
// leaking potentially internal information to our RP.
uri, _ := url.Parse(i.authorizationEndpointURI.String())
query, _ := url.ParseQuery(req.URL.RawQuery)
query.Del("flow")
query.Set("error", typedErr.ErrorID)
query.Set("error_description", "identifier failed to authenticate")
uri.RawQuery = query.Encode()
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
return
default:
i.logger.WithError(err).Errorln("identifier failed to process saml2 start")
i.ErrorPage(rw, http.StatusInternalServerError, "", "saml2 start failed")
return
}
sd := &StateData{
State: rndm.GenerateRandomString(32),
RawQuery: req.URL.RawQuery,
Ref: authority.ID,
}
uri, extra, err := authority.MakeRedirectAuthenticationRequestURL(sd.State)
if err != nil {
i.logger.WithError(err).Errorln("identifier failed to create authentication request: %w", err)
i.ErrorPage(rw, http.StatusInternalServerError, "", "saml2 start failed")
return
}
sd.Extra = extra
// Set cookie which is consumed by the callback later.
err = i.SetStateToStateCookie(req.Context(), rw, "saml2/acs", sd)
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to set saml2 state cookie")
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to set cookie")
return
}
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
}
func (i *Identifier) writeSAML2AssertionConsumerService(rw http.ResponseWriter, req *http.Request) {
var err error
var sd *StateData
var user *IdentifiedUser
var authority *authorities.Details
for {
sd, err = i.GetStateFromStateCookie(req.Context(), rw, req, "saml2/acs", req.Form.Get("RelayState"))
if err != nil {
err = fmt.Errorf("failed to decode saml2 acs state: %v", err)
break
}
if sd == nil {
err = errors.New("state not found")
break
}
// Load authority with client_id in state.
authority, _ = i.authorities.Lookup(req.Context(), sd.Ref)
if authority == nil {
i.logger.Debugln("identifier failed to find authority in saml2 acs")
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2InvalidRequest, "unknown client_id")
break
}
if authority.AuthorityType != authorities.AuthorityTypeSAML2 {
err = errors.New("unknown authority type")
break
}
// Parse incoming state response.
var assertion *saml.Assertion
if assertionRaw, parseErr := authority.ParseStateResponse(req, sd.State, sd.Extra); parseErr == nil {
assertion = assertionRaw.(*saml.Assertion)
} else {
err = parseErr
break
}
// Lookup username and user.
un, claims, claimsErr := authority.IdentityClaimValue(assertion)
if claimsErr != nil {
i.logger.WithError(claimsErr).Debugln("identifier failed to get username from saml2 acs assertion")
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2InsufficientScope, "identity claim not found")
break
}
username := &un
// TODO(longsleep): This flow currently does not provide a hello
// context, means that downwards a backend might fail to resolve the
// user when it requires additional information for multiple backend
// routing.
user, err = i.resolveUser(req.Context(), *username)
if err != nil {
i.logger.WithError(err).WithField("username", *username).Debugln("identifier failed to resolve saml2 acs user with backend")
// TODO(longsleep): Break on validation error.
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2AccessDenied, "failed to resolve user")
break
}
if user == nil || user.Subject() == "" {
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2AccessDenied, "no such user")
break
}
// Apply additional authority claims.
if sessionNotOnOrAfter, ok := claims["SessionNotOnOrAfter"]; ok {
user.expiresAfter = sessionNotOnOrAfter.(*time.Time)
}
var logonRef string
if nameIDTransient, ok := claims["TransientNameID"]; ok {
logonRef = "transient:" + nameIDTransient.(string)
} else if nameIDPersistent, ok := claims["PersistentNameID"]; ok {
logonRef = "persistent:" + nameIDPersistent.(string)
} else if nameIDUnspecified, ok := claims["UnspecifiedNameID"]; ok {
logonRef = "unspecified:" + nameIDUnspecified.(string)
}
if logonRef != "" {
user.logonRef = &logonRef
}
if authority.Trusted {
// Use external authority session, if the external authority is trusted.
if sessionIndexString, ok := claims["SessionIndex"]; ok {
sessionIndex := sessionIndexString.(string)
user.sessionRef = &sessionIndex
}
}
// Get user meta data.
// TODO(longsleep): This is an additional request to the backend. This
// should be avoided. Best would be if the backend would return everything
// in one shot (TODO in core).
err = i.updateUser(req.Context(), user, authority)
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to get user data in saml2 acs request")
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2AccessDenied, "failed to get user data")
break
}
// Set logon time.
user.logonAt = time.Now()
err = i.SetUserToLogonCookie(req.Context(), rw, user)
if err != nil {
i.logger.WithError(err).Errorln("identifier failed to serialize logon ticket in saml2 acs")
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to serialize logon ticket")
return
}
break
}
if sd == nil {
i.logger.WithError(err).Debugln("identifier saml2 acs without state")
i.ErrorPage(rw, http.StatusBadRequest, "", "state not found")
return
}
uri, _ := url.Parse(i.authorizationEndpointURI.String())
query, _ := url.ParseQuery(sd.RawQuery)
query.Del("flow")
query.Set("identifier", MustBeSignedIn)
query.Set("prompt", oidc.PromptNone)
switch typedErr := err.(type) {
case nil:
// breaks
case *saml.InvalidResponseError:
i.logger.WithError(err).WithFields(logrus.Fields{
"reason": typedErr.PrivateErr,
}).Debugf("saml2 acs invalid response")
query.Set("error", oidc.ErrorCodeOAuth2AccessDenied)
query.Set("error_description", "identifier received invalid response")
// breaks
case *konnectoidc.OAuth2Error:
// Pass along OAuth2 error.
i.logger.WithFields(utils.ErrorAsFields(err)).Debugln("saml2 acs error")
// NOTE(longsleep): Pass along error ID but not the description to avoid
// leaking potetially internal information to our RP.
query.Set("error", typedErr.ErrorID)
query.Set("error_description", "identifier failed to authenticate")
//breaks
default:
i.logger.WithError(err).Errorln("identifier failed to process saml2 acs")
i.ErrorPage(rw, http.StatusInternalServerError, "", "saml2 acs failed")
return
}
uri.RawQuery = query.Encode()
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
}
func (i *Identifier) writeSAMLSingleLogoutServiceRequest(rw http.ResponseWriter, req *http.Request) {
lor, err := samlext.NewIdpLogoutRequest(req)
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to process saml2 slo request")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to parse request")
return
}
err = lor.Validate()
if err != nil {
i.logger.WithError(err).Debugln("identifier saml2 slo request validation failed")
i.ErrorPage(rw, http.StatusBadRequest, "", "slo request validation failed")
return
}
// In http://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf §3.4.5.2
// we get a description of the Destination attribute:
//
// If the message is signed, the Destination XML attribute in the root SAML
// element of the protocol message MUST contain the URL to which the sender
// has instructed the user agent to deliver the message. The recipient MUST
// then verify that the value matches the location at which the message has
// been received.
//
// We require the destination be correct either (a) if signing is enabled or
// (b) if it was provided.
mustHaveDestination := lor.SigAlg != nil
mustHaveDestination = mustHaveDestination || lor.Request.Destination != ""
if mustHaveDestination {
uri, _ := i.absoluteURLForRoute("saml2/slo")
if lor.Request.Destination != uri.String() {
i.logger.WithField("destination", lor.Request.Destination).Debugln("identifier saml2 slo request with wrong desitation")
i.ErrorPage(rw, http.StatusBadRequest, "", "slo request destination wrong")
return
}
}
// Find matching authority.
authority, found := i.authorities.Find(req.Context(), func(authority authorities.AuthorityRegistration) bool {
if authority.AuthorityType() != authorities.AuthorityTypeSAML2 {
return false
}
if lor.Request.Issuer.Value == authority.Issuer() {
return true
}
return false
})
if !found {
i.logger.WithField("issuer", lor.Request.Issuer.Value).Debugln("identifier saml2 slo request from unknown issuer")
i.ErrorPage(rw, http.StatusBadRequest, "", "slo request issuer unknown")
return
}
authorityDetails := authority.Authority()
if lor.SigAlg == nil {
// Never consider trusted if not signed.
authorityDetails.Trusted = false
}
if authorityDetails.AuthorityType != authorities.AuthorityTypeSAML2 {
i.logger.WithField("issuer", lor.Request.Issuer.Value).Debugln("identifier saml2 slo request for unknown authority type")
i.ErrorPage(rw, http.StatusBadRequest, "", "slo request issuer authority type unknown")
return
}
// Validate.
validated, err := authority.ValidateIdpEndSessionRequest(lor, lor.RelayState)
if err != nil {
i.logger.WithError(err).WithField("issuer", authority.Issuer()).Debugln("identifier saml2 slo request authority validation failed")
i.ErrorPage(rw, http.StatusBadRequest, "", "slo request authority validation failed")
return
}
if !validated && authorityDetails.Trusted {
// Never consider unvalidated logout requests as trusted.
authorityDetails.Trusted = false
}
user, _ := i.GetUserFromLogonCookie(req.Context(), req, 0, false)
if user != nil {
// Compare signed in SAML SessionIndex with the on provided in the LogoutRequest.
if user.SessionRef() != nil {
if lor.Request.SessionIndex == nil {
i.logger.Debugln("identifier saml2 slo request without session index")
i.ErrorPage(rw, http.StatusBadRequest, "", "slo request missing session index")
return
}
if lor.Request.SessionIndex.Value != *user.SessionRef() {
i.logger.Debugln("identifier saml2 slo request for other session index")
i.ErrorPage(rw, http.StatusBadRequest, "", "slo request session index mismatch")
return
}
}
if authorityDetails != nil && authorityDetails.Trusted {
// Directly clear identifier session when a trusted authority requests it.
err = i.UnsetLogonCookie(req.Context(), user, rw)
if err != nil {
i.logger.WithError(err).Errorln("identifier saml2 slo failed to unset logon cookie")
i.ErrorPage(rw, http.StatusInternalServerError, "", "saml2 slo logout failed")
return
}
}
} else {
// Ignore when not signed in, for end session.
}
if authorityDetails == nil || !authorityDetails.Trusted {
// Handle directly by redirecting to our logout confirm url for untrusted
// registies or when no URL was set.
uri, _ := i.absoluteURLForRoute("goodbye")
query := &url.Values{}
uri.RawQuery = query.Encode()
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
return
}
uri, _, err := authorityDetails.MakeRedirectEndSessionResponseURL(lor.Request, lor.RelayState)
if err != nil {
i.logger.WithError(err).Errorln("failed to make saml2 slo redirect request url")
i.ErrorPage(rw, http.StatusInternalServerError, "", "saml2 slo failed")
return
}
if uri == nil {
i.logger.Warnln("saml2 slo reached dead end, no post logout redirect uri available")
// Fall back to logout confirm url.
uri, _ = i.absoluteURLForRoute("goodbye")
}
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
}
func (i *Identifier) writeSAMLSingleLogoutServiceResponse(rw http.ResponseWriter, req *http.Request) {
lor, err := samlext.NewIdpLogoutResponse(req)
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to process saml2 slo response")
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to parse response")
return
}
err = lor.Validate()
if err != nil {
i.logger.WithError(err).Debugln("identifier saml2 slo response validation failed")
i.ErrorPage(rw, http.StatusBadRequest, "", "response validation failed")
return
}
sd, err := i.GetStateFromStateCookie(req.Context(), rw, req, "_/saml2/slo", lor.RelayState)
if err != nil {
i.logger.WithError(err).Debugln("identifier saml2 slo response failed to load state")
i.ErrorPage(rw, http.StatusBadRequest, "", "response state invalid")
return
}
if sd == nil {
i.logger.WithError(err).Debugln("identifier saml2 slo response failed as state is missing")
i.ErrorPage(rw, http.StatusBadRequest, "", "response state missing")
return
}
authority, found := i.authorities.Get(req.Context(), sd.Ref)
if !found {
i.ErrorPage(rw, http.StatusBadRequest, "", "no authority")
return
}
authorityDetails := authority.Authority()
if lor.SigAlg == nil {
// Never consider trusted if not signed.
authorityDetails.Trusted = false
}
if authorityDetails.AuthorityType != authorities.AuthorityTypeSAML2 {
i.logger.WithField("issuer", authority.Issuer()).Debugln("identifier saml2 slo response for unknown authority type")
i.ErrorPage(rw, http.StatusBadRequest, "", "slo response issuer authority type unknown")
return
}
// Validate.
validated, err := authority.ValidateIdpEndSessionResponse(lor, lor.RelayState)
if err != nil {
i.logger.WithError(err).WithField("issuer", authority.Issuer()).Debugln("identifier saml2 slo response authority validation failed")
i.ErrorPage(rw, http.StatusBadRequest, "", "slo response authority validation failed")
return
}
if !validated && authorityDetails.Trusted {
// Never consider unvalidated logout responses as trusted.
authorityDetails.Trusted = false
}
if lor.Response.Status.StatusCode.Value != saml.StatusSuccess {
i.logger.WithField("status", lor.Response.Status.StatusCode).Debugln("saml2 slo response without success status")
}
// Extract destination URI from state data (its put into the RawQuery field).
uri, err := url.Parse(sd.RawQuery)
if err != nil {
i.logger.WithError(err).Errorln("failed to parse slo response redirect url from state data")
i.ErrorPage(rw, http.StatusInternalServerError, "", "saml2 slo response failed")
return
}
if uri == nil || uri.String() == "" {
i.logger.Warnln("saml2 slo reached dead end, no post logout redirect uri available")
// Fall back to our signed out url or goodbye route.
if i.Config.SignedOutEndpointURI != nil {
uri = i.Config.SignedOutEndpointURI
} else {
uri, _ = i.absoluteURLForRoute("goodbye")
}
}
if sd.State != "" {
query := uri.Query()
query.Set("state", sd.State)
uri.RawQuery = query.Encode()
}
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
}
+73
View File
@@ -0,0 +1,73 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package identifier
import (
"html/template"
"net/http"
"net/url"
"github.com/libregraph/lico/version"
)
var trampolinTemplate = template.Must(template.New("trampolin").Parse(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body trampolin="{{.URI}}">
<script src="trampolin/trampolin.js?v={{.Version}}"></script>
<noscript>Javascript is required for this app.</noscript>
</body>
</html>
`))
var trampolinScript = []byte(`(function(window) {
window.location.replace(document.body.getAttribute('trampolin'));
}(window));
`)
var trampolinVersion = url.QueryEscape(version.Version)
type trampolinData struct {
URI string
Version string
}
func (i *Identifier) writeTrampolinHTML(rw http.ResponseWriter, req *http.Request, uri *url.URL) {
data := &trampolinData{
URI: uri.String(),
Version: trampolinVersion,
}
rw.Header().Set("Content-Type", "text/html")
rw.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
rw.Header().Set("Pragma", "no-cache")
rw.Header().Set("Expires", "0")
err := trampolinTemplate.Execute(rw, data)
if err != nil {
i.logger.WithError(err).Errorln("failed to write trampolin")
}
}
func (i *Identifier) writeTrampolinScript(rw http.ResponseWriter, req *http.Request) {
rw.Header().Set("Content-Type", "application/javascript")
rw.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
rw.Write(trampolinScript)
}
+32
View File
@@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": [
"DOM",
"DOM.Iterable",
"ESNext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": [
"src/vite-env.d.ts",
"vite.config.ts"
]
}
+251
View File
@@ -0,0 +1,251 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package identifier
import (
"context"
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
konnect "github.com/libregraph/lico"
"github.com/libregraph/lico/identifier/backends"
"github.com/libregraph/lico/identity"
"github.com/libregraph/lico/identity/authorities"
)
// A IdentifiedUser is a user with meta data.
type IdentifiedUser struct {
sub string
backend backends.Backend
externalAuthority *authorities.Details
username string
email string
emailVerified bool
displayName string
familyName string
givenName string
id int64
uid string
sessionRef *string
logonRef *string
claims map[string]interface{}
scopes []string
logonAt time.Time
expiresAfter *time.Time
lockedScopes []string
}
// Subject returns the associated users subject field. The subject is the main
// authentication identifier of the user.
func (u *IdentifiedUser) Subject() string {
return u.sub
}
// Email returns the associated users email field.
func (u *IdentifiedUser) Email() string {
return u.email
}
// EmailVerified returns trye if the associated users email field was verified.
func (u *IdentifiedUser) EmailVerified() bool {
return u.emailVerified
}
// Name returns the associated users name field. This is the display name of
// the accociated user.
func (u *IdentifiedUser) Name() string {
return u.displayName
}
// FamilyName returns the associated users family name field.
func (u *IdentifiedUser) FamilyName() string {
return u.familyName
}
// GivenName returns the associated users given name field.
func (u *IdentifiedUser) GivenName() string {
return u.givenName
}
// ID returns the associated users numeric user id. If it is 0, it means that
// this user does not have a numeric ID. Do not use this field to identify a
// user - always use the subject instead. The numeric ID is kept for compatibility
// with systems which require user identification to be numeric.
func (u *IdentifiedUser) ID() int64 {
return u.id
}
// UniqueID returns the accociated users unique user id. When empty, then this
// user does not have a unique ID. This field can be used for unique user mapping
// to external systems which use the same authentication source as Konnect. The
// value depends entirely on the identifier backend.
func (u *IdentifiedUser) UniqueID() string {
return u.uid
}
// Username returns the accociated users username. This might be different or
// the same as the subject, depending on the backend in use. If can also be
// empty, which means that the accociated user does not have a username.
func (u *IdentifiedUser) Username() string {
return u.username
}
// Claims returns extra claims of the accociated user.
func (u *IdentifiedUser) Claims() jwt.MapClaims {
claims := make(map[string]interface{})
claims[konnect.IdentifiedUsernameClaim] = u.Username()
claims[konnect.IdentifiedDisplayNameClaim] = u.Name()
for k, v := range u.claims {
claims[k] = v
}
return jwt.MapClaims(claims)
}
// ScopedClaims returns scope bound extra claims of the accociated user.
func (u *IdentifiedUser) ScopedClaims(authorizedScopes map[string]bool) jwt.MapClaims {
if u.backend == nil {
return nil
}
claims := u.backend.UserClaims(u.Subject(), authorizedScopes)
return jwt.MapClaims(claims)
}
// Scopes returns the scopes attached to this user.
func (u *IdentifiedUser) Scopes() []string {
return u.scopes
}
// LoggedOn returns true if the accociated user has a logonAt time set.
func (u *IdentifiedUser) LoggedOn() (bool, time.Time) {
return !u.logonAt.IsZero(), u.logonAt
}
// SessionRef returns the accociated users underlaying session reference.
func (u *IdentifiedUser) SessionRef() *string {
return u.sessionRef
}
// UserRef returns the accociated users underlaying logon reference.
func (u *IdentifiedUser) LogonRef() *string {
return u.logonRef
}
func (u *IdentifiedUser) ExternalAuthorityID() *string {
if u.externalAuthority == nil {
return nil
}
id := u.externalAuthority.ID
return &id
}
// BackendName returns the accociated users underlaying backend name.
func (u *IdentifiedUser) BackendName() string {
return u.backend.Name()
}
func (u *IdentifiedUser) LockedScopes() []string {
return u.lockedScopes
}
func (i *Identifier) logonUser(ctx context.Context, audience, username, password string) (*IdentifiedUser, error) {
success, subject, sessionRef, u, err := i.backend.Logon(ctx, audience, username, password)
if err != nil {
return nil, err
}
if !success || u == nil {
return nil, nil
}
user := &IdentifiedUser{
sub: *subject,
username: u.Username(),
backend: i.backend,
sessionRef: sessionRef,
claims: u.BackendClaims(),
lockedScopes: u.RequiredScopes(),
}
return user, nil
}
func (i *Identifier) resolveUser(ctx context.Context, username string) (*IdentifiedUser, error) {
u, err := i.backend.ResolveUserByUsername(ctx, username)
if err != nil {
return nil, err
}
if u == nil {
return nil, nil
}
// Construct user from resolved result.
user := &IdentifiedUser{
sub: u.Subject(),
username: u.Username(),
backend: i.backend,
claims: u.BackendClaims(),
lockedScopes: u.RequiredScopes(),
}
return user, nil
}
func (i *Identifier) updateUser(ctx context.Context, user *IdentifiedUser, externalAuthority *authorities.Details) error {
var userID string
identityClaims := user.Claims()
if userIDString, ok := identityClaims[konnect.IdentifiedUserIDClaim]; ok {
userID = userIDString.(string)
}
if userID == "" {
return errors.New("no id claim in user identity claims")
}
u, err := i.backend.GetUser(ctx, userID, user.sessionRef, nil)
if err != nil {
return err
}
if uwp, ok := u.(identity.UserWithProfile); ok {
user.displayName = uwp.Name()
}
user.backend = i.backend
user.externalAuthority = externalAuthority
return nil
}
+55
View File
@@ -0,0 +1,55 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package identifier
import (
"encoding/base64"
"fmt"
"net/http"
"strings"
"time"
"github.com/gabriel-vasile/mimetype"
)
var (
farPastExpiryTime = time.Unix(0, 0)
farPastExpiryTimeHTTPHeaderString = farPastExpiryTime.UTC().Format(http.TimeFormat)
)
func addCommonResponseHeaders(header http.Header) {
header.Set("X-Frame-Options", "DENY")
header.Set("X-XSS-Protection", "1; mode=block")
header.Set("X-Content-Type-Options", "nosniff")
header.Set("Referrer-Policy", "origin")
}
func addNoCacheResponseHeaders(header http.Header) {
header.Set("Cache-Control", "no-cache, no-store, must-revalidate")
header.Set("Pragma", "no-cache")
header.Set("Expires", farPastExpiryTimeHTTPHeaderString)
}
func encodeImageAsDataURL(b []byte) (string, error) {
mt := mimetype.Detect(b)
if !strings.HasPrefix(mt.String(), "image/") {
return "", fmt.Errorf("not an image: %s", mt)
}
return "data:" + mt.String() + ";base64," + base64.StdEncoding.EncodeToString(b), nil
}
+70
View File
@@ -0,0 +1,70 @@
import { defineConfig, splitVendorChunkPlugin } from "vite";
import react from "@vitejs/plugin-react";
import checker from "vite-plugin-checker";
import legacy from "@vitejs/plugin-legacy";
const addScriptCSPNoncePlaceholderPlugin = () => {
return {
name: "add-script-nonce-placeholderP-plugin",
apply: "build",
transformIndexHtml: {
order: "post",
handler(htmlData) {
return htmlData.replaceAll(
/<script nomodule>/gi,
`<script nomodule nonce="__CSP_NONCE__">`
).replaceAll(
/<script type="module">/gi,
`<script type="module" nonce="__CSP_NONCE__">`
).replaceAll(
/<script nomodule crossorigin id="vite-legacy-entry"/gi,
`<script nomodule crossorigin id="vite-legacy-entry" nonce="__CSP_NONCE__"`
);
},
},
};
};
export default defineConfig((env) => {
return {
build: {
outDir: 'build',
assetsDir: 'static/assets',
manifest: 'asset-manifest.json',
sourcemap: true,
},
base: './',
server: {
port: 3001,
strictPort: true,
host: '127.0.0.1',
hmr: {
protocol: 'ws',
host: '127.0.0.1',
clientPort: 3001,
},
},
plugins: [
react(),
legacy({
targets: ['edge 18'],
}),
env.mode !== "test" &&
checker({
typescript: true,
eslint: {
lintCommand: 'eslint --max-warnings=0 src',
},
}),
splitVendorChunkPlugin(),
addScriptCSPNoncePlaceholderPlugin(),
],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './tests/setup.js',
},
};
});
+43
View File
@@ -0,0 +1,43 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package identity
import (
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/libregraph/lico/oidc/payload"
)
// AuthRecord is an interface which provides identity auth information with scopes and claims..
type AuthRecord interface {
Manager() Manager
Subject() string
AuthorizedScopes() map[string]bool
AuthorizeScopes(map[string]bool)
AuthorizedClaims() *payload.ClaimsRequest
AuthorizeClaims(*payload.ClaimsRequest)
Claims(...string) []jwt.Claims
User() PublicUser
SetUser(PublicUser)
LoggedOn() (bool, time.Time)
SetAuthTime(time.Time)
}
@@ -0,0 +1,139 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package authorities
import (
"crypto"
"errors"
"fmt"
"net/http"
"net/url"
"github.com/golang-jwt/jwt/v5"
"github.com/libregraph/oidc-go"
)
// Details hold immutable information about external authorities identified by ID.
type Details struct {
ID string
Name string
AuthorityType string
ClientID string
ClientSecret string
Trusted bool
Insecure bool
Scopes []string
ResponseType string
ResponseMode string
CodeChallengeMethod string
EndSessionEnabled bool
registration AuthorityRegistration
ready bool
validationKeys map[string]crypto.PublicKey
}
// IsReady returns wether or not the associated registration entry was ready
// at time of creation of the associated details.
func (d *Details) IsReady() bool {
return d.ready
}
// IdentityClaimValue returns the identity claim value from the provided data.
func (d *Details) IdentityClaimValue(claims interface{}) (string, map[string]interface{}, error) {
return d.registration.IdentityClaimValue(claims)
}
// MakeRedirectAuthenticationRequestURL returns the authentication request
// URL which can be used to initiate authentication with the associated
// authority. It takes a state as parameter and in addition to the URL it also
// returns a mapping of extra state data and potentially an error.
func (d *Details) MakeRedirectAuthenticationRequestURL(state string) (*url.URL, map[string]interface{}, error) {
return d.registration.MakeRedirectAuthenticationRequestURL(state)
}
// MakeRedirectEndSessionRequestURL returns the end session request URL which
// can be used to initiate end session with the associated authority. It takes
// a state as paraeter and in addition to the URL it also returns a mappting
// of extra state data and potentially an error.
func (d *Details) MakeRedirectEndSessionRequestURL(ref interface{}, state string) (*url.URL, map[string]interface{}, error) {
return d.registration.MakeRedirectEndSessionRequestURL(ref, state)
}
// MakeRedirectEndSessionResponseURL returns the end session response URL which
// can be used to redirect back the response for an incoming end session request.
// It takes the authority specific request and a state, returning the destination
// url, additional state mapping and potential error.
func (d *Details) MakeRedirectEndSessionResponseURL(req interface{}, state string) (*url.URL, map[string]interface{}, error) {
return d.registration.MakeRedirectEndSessionResponseURL(req, state)
}
// ParseStateResponse takes an incoming request, a state and optional extra data
// and returns the parsed authority specific response data for that request or
// error.
func (d *Details) ParseStateResponse(req *http.Request, state string, extra map[string]interface{}) (interface{}, error) {
return d.registration.ParseStateResponse(req, state, extra)
}
// JWTKeyfunc returns a key func to validate JWTs with the keys of the associated
// authority registration.
func (d *Details) JWTKeyfunc() jwt.Keyfunc {
return d.validateJWT
}
func (d *Details) validateJWT(token *jwt.Token) (interface{}, error) {
rawAlg, ok := token.Header[oidc.JWTHeaderAlg]
if !ok {
return nil, errors.New("no alg header")
}
alg, ok := rawAlg.(string)
if !ok {
return nil, errors.New("invalid alg value")
}
switch jwt.GetSigningMethod(alg).(type) {
case *jwt.SigningMethodRSA:
case *jwt.SigningMethodECDSA:
case *jwt.SigningMethodRSAPSS:
default:
return nil, fmt.Errorf("unexpected alg value")
}
rawKid, ok := token.Header[oidc.JWTHeaderKeyID]
if !ok {
return nil, fmt.Errorf("no kid header")
}
kid, ok := rawKid.(string)
if !ok {
return nil, fmt.Errorf("invalid kid value")
}
if key, ok := d.validationKeys[kid]; ok {
return key, nil
}
return nil, errors.New("no key available")
}
func (d *Details) Metadata() interface{} {
return d.registration.Metadata()
}
+103
View File
@@ -0,0 +1,103 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package authorities
import (
"context"
"net/http"
"net/url"
"github.com/go-jose/go-jose/v3"
)
// Supported Authority kind string values.
const (
AuthorityTypeOIDC = "oidc"
AuthorityTypeSAML2 = "saml2"
)
type authorityRegistrationData struct {
ID string `json:"id"`
Name string `json:"name"`
AuthorityType string `json:"authority_type"`
Iss string `json:"iss"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
EntityID string `json:"entity_id"`
Trusted bool `json:"trusted"`
Insecure bool `json:"insecure"`
Default bool `json:"default"`
Discover *bool `json:"discover"`
Scopes []string `json:"scopes"`
ResponseType string `json:"response_type"`
ResponseMode string `json:"response_mode"`
CodeChallengeMethod string `json:"code_challenge_method"`
RawMetadataEndpoint string `json:"metadata_endpoint"`
RawAuthorizationEndpoint string `json:"authorization_endpoint"`
RawTokenEndpoint string `json:"token_endpoint"`
UserInfoEndpoint string `json:"user_info_endpoint"`
JWKS *jose.JSONWebKeySet `json:"jwks"`
IdentityClaimName string `json:"identity_claim_name"`
IdentityAliases map[string]string `json:"identity_aliases"`
IdentityAliasRequired bool `json:"identity_alias_required"`
EndSessionEnabled bool `json:"end_session_enabled"`
}
type authorityRegistryData struct {
Authorities []*authorityRegistrationData `json:"authorities"`
}
// AuthorityRegistration defines an authority with its properties.
type AuthorityRegistration interface {
ID() string
Name() string
AuthorityType() string
Authority() *Details
Issuer() string
Validate() error
Initialize(ctx context.Context, registry *Registry) error
MakeRedirectAuthenticationRequestURL(state string) (*url.URL, map[string]interface{}, error)
MakeRedirectEndSessionRequestURL(ref interface{}, state string) (*url.URL, map[string]interface{}, error)
MakeRedirectEndSessionResponseURL(req interface{}, state string) (*url.URL, map[string]interface{}, error)
ParseStateResponse(req *http.Request, state string, extra map[string]interface{}) (interface{}, error)
ValidateIdpEndSessionRequest(req interface{}, state string) (bool, error)
ValidateIdpEndSessionResponse(res interface{}, state string) (bool, error)
IdentityClaimValue(data interface{}) (string, map[string]interface{}, error)
Metadata() AuthorityMetadata
}
type AuthorityMetadata interface {
}
+510
View File
@@ -0,0 +1,510 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package authorities
import (
"context"
"crypto"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"github.com/go-jose/go-jose/v3"
"github.com/golang-jwt/jwt/v5"
"github.com/libregraph/oidc-go"
"github.com/sirupsen/logrus"
konnectoidc "github.com/libregraph/lico/oidc"
"github.com/libregraph/lico/oidc/payload"
"github.com/libregraph/lico/utils"
)
// Authority default values.
var (
oidcAuthorityDefaultScopes = []string{oidc.ScopeOpenID, oidc.ScopeProfile}
oidcAuthorityDefaultResponseType = oidc.ResponseTypeCode
oidcAuthorityDefaultResponseMode = oidc.ResponseModeQuery
oidcAuthorityDefaultCodeChallengeMethod = oidc.S256CodeChallengeMethod
oidcAuthorityDefaultIdentityClaimName = oidc.PreferredUsernameClaim
)
type oidcAuthorityRegistration struct {
registry *Registry
data *authorityRegistrationData
discover bool
metadataEndpoint *url.URL
authorizationEndpoint *url.URL
tokenEndpoint *url.URL
endSessionEndpoint *url.URL
userInfoEndpoint *url.URL
validationKeys map[string]crypto.PublicKey
mutex sync.RWMutex
ready bool
wellKnown *oidc.WellKnown
}
func newOIDCAuthorityRegistration(registry *Registry, registrationData *authorityRegistrationData) (*oidcAuthorityRegistration, error) {
ar := &oidcAuthorityRegistration{
registry: registry,
data: registrationData,
}
if ar.data.RawMetadataEndpoint != "" {
if u, err := url.Parse(ar.data.RawMetadataEndpoint); err == nil {
ar.metadataEndpoint = u
} else {
return nil, fmt.Errorf("invalid metadata_endpoint value: %v", err)
}
}
if ar.data.RawAuthorizationEndpoint != "" {
if u, err := url.Parse(ar.data.RawAuthorizationEndpoint); err == nil {
if u.Scheme != "https" {
return nil, errors.New("authorization_endpoint must be https")
}
ar.authorizationEndpoint = u
} else {
return nil, fmt.Errorf("invalid authorization_endpoint value: %v", err)
}
}
if ar.data.RawTokenEndpoint != "" {
if u, err := url.Parse(ar.data.RawTokenEndpoint); err == nil {
if u.Scheme != "https" {
return nil, errors.New("token_endpoint must be https")
}
ar.tokenEndpoint = u
} else {
return nil, fmt.Errorf("invalid token_endpoint value: %v", err)
}
}
if ar.data.UserInfoEndpoint != "" {
if u, err := url.Parse(ar.data.UserInfoEndpoint); err == nil {
if u.Scheme != "https" {
return nil, errors.New("userinfo_endpoint must be https")
}
ar.userInfoEndpoint = u
} else {
return nil, fmt.Errorf("invalid userinfo_endpoint value: %v", err)
}
}
if ar.data.JWKS != nil {
if err := ar.setValidationKeysFromJWKS(ar.data.JWKS, false); err != nil {
return nil, err
}
}
if ar.data.Discover != nil {
ar.discover = *ar.data.Discover
}
// Additional behavior.
if ar.metadataEndpoint == nil && (ar.data.Discover == nil || ar.discover == true) {
if ar.data.Iss == "" {
return nil, fmt.Errorf("oidc authority iss is empty")
}
if issuer, err := url.Parse(ar.data.Iss); err == nil {
relativeWellKnownURI, parseErr := url.Parse(strings.TrimRight(issuer.Path, "/") + "/.well-known/openid-configuration")
if parseErr != nil {
return nil, parseErr
}
ar.metadataEndpoint = issuer.ResolveReference(relativeWellKnownURI)
ar.discover = true
} else {
return nil, fmt.Errorf("invalid iss value: %v", err)
}
}
if !ar.discover {
if ar.authorizationEndpoint == nil {
return nil, errors.New("authorization_endpoint is empty")
}
if ar.data.JWKS == nil && !ar.data.Insecure {
return nil, errors.New("jwks is empty")
}
}
return ar, nil
}
func (ar *oidcAuthorityRegistration) ID() string {
return ar.data.ID
}
func (ar *oidcAuthorityRegistration) Name() string {
return ar.data.Name
}
func (ar *oidcAuthorityRegistration) AuthorityType() string {
return ar.data.AuthorityType
}
func (ar *oidcAuthorityRegistration) Authority() *Details {
details := &Details{
ID: ar.data.ID,
Name: ar.data.Name,
AuthorityType: ar.data.AuthorityType,
ClientID: ar.data.ClientID,
ClientSecret: ar.data.ClientSecret,
Trusted: ar.data.Trusted,
Insecure: ar.data.Insecure,
Scopes: ar.data.Scopes,
ResponseType: ar.data.ResponseType,
ResponseMode: ar.data.ResponseMode,
CodeChallengeMethod: ar.data.CodeChallengeMethod,
EndSessionEnabled: ar.data.EndSessionEnabled,
registration: ar,
}
ar.mutex.RLock()
details.ready = ar.ready
if ar.ready {
details.validationKeys = ar.validationKeys
}
ar.mutex.RUnlock()
return details
}
func (ar *oidcAuthorityRegistration) Issuer() string {
return ar.data.Iss
}
func (ar *oidcAuthorityRegistration) setValidationKeysFromJWKS(jwks *jose.JSONWebKeySet, skipInvalid bool) error {
if jwks == nil || len(jwks.Keys) == 0 {
ar.validationKeys = nil
return nil
}
ar.validationKeys = make(map[string]crypto.PublicKey)
skipped := 0
for _, jwk := range jwks.Keys {
if jwk.Use == "sig" {
if key, ok := jwk.Key.(crypto.PublicKey); ok {
ar.validationKeys[jwk.KeyID] = key
} else {
if !skipInvalid {
return fmt.Errorf("failed to decode public key")
} else {
skipped++
}
}
}
}
if skipped > 0 {
return fmt.Errorf("failed to decode %d keys in set", skipped)
}
return nil
}
func (ar *oidcAuthorityRegistration) Validate() error {
if ar.data.ClientID == "" {
return errors.New("invalid authority client_id")
}
// Ensure some defaults.
if len(ar.data.Scopes) == 0 {
ar.data.Scopes = oidcAuthorityDefaultScopes
}
if ar.data.ResponseType == "" {
ar.data.ResponseType = oidcAuthorityDefaultResponseType
}
if ar.data.ResponseMode == "" {
ar.data.ResponseMode = oidcAuthorityDefaultResponseMode
}
if ar.data.CodeChallengeMethod == "" {
ar.data.CodeChallengeMethod = oidcAuthorityDefaultCodeChallengeMethod
}
if ar.data.IdentityClaimName == "" {
ar.data.IdentityClaimName = oidcAuthorityDefaultIdentityClaimName
}
return nil
}
func (ar *oidcAuthorityRegistration) Initialize(ctx context.Context, registry *Registry) error {
ar.mutex.Lock()
defer ar.mutex.Unlock()
if ar.authorizationEndpoint != nil && ar.validationKeys != nil {
ar.ready = true
}
if ar.metadataEndpoint == nil {
return fmt.Errorf("no metadata_endpoint set")
}
return initializeOIDC(ctx, registry.logger, ar)
}
func (ar *oidcAuthorityRegistration) IdentityClaimValue(rawToken interface{}) (string, map[string]interface{}, error) {
idToken, _ := rawToken.(*jwt.Token)
if idToken == nil {
return "", nil, errors.New("invalid ID token data")
}
claims, _ := idToken.Claims.(jwt.MapClaims)
if claims == nil {
return "", nil, errors.New("invalid claims data")
}
icn := ar.data.IdentityClaimName
if icn == "" {
icn = oidc.PreferredUsernameClaim
}
cvr, ok := claims[icn]
if !ok {
return "", nil, errors.New("identity claim not found")
}
cvs, ok := cvr.(string)
if !ok {
return "", nil, errors.New("identify claim has invalid type")
}
// Add extra external authority claims, for example SessionIndex.
extra := make(map[string]interface{})
extra["RawIDToken"] = idToken.Raw
// Convert claim value.
whitelisted := false
if ar.data.IdentityAliases != nil {
if alias, ok := ar.data.IdentityAliases[cvs]; ok && alias != "" {
cvs = alias
whitelisted = true
}
}
// Check whitelist.
if ar.data.IdentityAliasRequired && !whitelisted {
return "", nil, errors.New("identity claim has no alias")
}
return cvs, extra, nil
}
func (ar *oidcAuthorityRegistration) MakeRedirectAuthenticationRequestURL(state string) (*url.URL, map[string]interface{}, error) {
ar.mutex.RLock()
defer ar.mutex.RUnlock()
if !ar.ready {
return nil, nil, errors.New("not ready")
}
uri, _ := url.Parse(ar.authorizationEndpoint.String())
query := make(url.Values)
query.Add("state", state)
uri.RawQuery = query.Encode()
return uri, nil, nil
}
func (ar *oidcAuthorityRegistration) MakeRedirectEndSessionRequestURL(ref interface{}, state string) (*url.URL, map[string]interface{}, error) {
ar.mutex.RLock()
defer ar.mutex.RUnlock()
if !ar.ready {
return nil, nil, errors.New("not ready")
}
logonRef := ref.(*string)
if logonRef == nil {
// Do nothing when we cannot provide id token hint.
return nil, nil, nil
}
uri, _ := url.Parse(ar.endSessionEndpoint.String())
query := make(url.Values)
query.Add("state", state)
query.Add("id_token_hint", *logonRef)
uri.RawQuery = query.Encode()
return uri, nil, nil
}
func (ar *oidcAuthorityRegistration) MakeRedirectEndSessionResponseURL(req interface{}, state string) (*url.URL, map[string]interface{}, error) {
return nil, nil, fmt.Errorf("idp end session not implemented")
}
func (ar *oidcAuthorityRegistration) ParseStateResponse(req *http.Request, state string, extra map[string]interface{}) (interface{}, error) {
if authenticationErrorID := req.Form.Get("error"); authenticationErrorID != "" {
// Incoming error case.
return nil, konnectoidc.NewOAuth2Error(authenticationErrorID, req.Form.Get("error_description"))
}
// Success case.
authenticationSuccess := &payload.AuthenticationSuccess{}
err := utils.DecodeURLSchema(authenticationSuccess, req.Form)
if err != nil {
return nil, fmt.Errorf("failed to parse oidc state response: %w", err)
}
return authenticationSuccess, nil
}
func (ar *oidcAuthorityRegistration) ValidateIdpEndSessionRequest(req interface{}, state string) (bool, error) {
return false, fmt.Errorf("not implemented")
}
func (ar *oidcAuthorityRegistration) ValidateIdpEndSessionResponse(res interface{}, state string) (bool, error) {
return false, fmt.Errorf("not implemented")
}
func (ar *oidcAuthorityRegistration) Metadata() AuthorityMetadata {
ar.mutex.RLock()
defer ar.mutex.RUnlock()
return &oidc.WellKnown{
Issuer: ar.data.Iss,
AuthorizationEndpoint: ar.authorizationEndpoint.String(),
TokenEndpoint: ar.tokenEndpoint.String(),
UserInfoEndpoint: ar.userInfoEndpoint.String(),
EndSessionEndpoint: ar.endSessionEndpoint.String(),
}
}
type oidcProviderLogger struct {
logger logrus.FieldLogger
}
func (logger *oidcProviderLogger) Printf(format string, args ...interface{}) {
logger.logger.Debugf(format, args...)
}
func initializeOIDC(ctx context.Context, logger logrus.FieldLogger, ar *oidcAuthorityRegistration) error {
providerLogger := logger.WithFields(logrus.Fields{
"id": ar.data.ID,
"type": AuthorityTypeOIDC,
})
config := &oidc.ProviderConfig{
Logger: &oidcProviderLogger{providerLogger},
HTTPHeader: http.Header{},
}
if ar.data.Insecure {
config.HTTPClient = utils.InsecureHTTPClient
} else {
config.HTTPClient = utils.DefaultHTTPClient
}
config.HTTPHeader.Set("User-Agent", utils.DefaultHTTPUserAgent)
issuer, err := url.Parse(ar.data.Iss)
if err != nil {
return fmt.Errorf("failed to parse issuer: %v", err)
}
if issuer.Scheme != "https" {
return fmt.Errorf("issuer scheme is not https")
}
if issuer.Host == "" {
return fmt.Errorf("issuer host is empty")
}
provider, err := oidc.NewProvider(issuer, config)
if err != nil {
return fmt.Errorf("failed to create oidc provider: %v", err)
}
updateCh := make(chan *oidc.ProviderDefinition)
errorCh := make(chan error)
err = provider.Initialize(ctx, updateCh, errorCh)
if err != nil {
return fmt.Errorf("failed to initialize oidc provider: %v", err)
}
go func() {
// Handle updates and errors of authority meta data.
var pd *oidc.ProviderDefinition
var jwks *jose.JSONWebKeySet
for {
pd = nil
select {
case <-ctx.Done():
return
case update := <-updateCh:
pd = update
case chErr := <-errorCh:
providerLogger.Errorf("error while oidc provider update: %v", chErr)
}
if pd != nil {
ar.mutex.Lock()
if pd.WellKnown != nil && pd.WellKnown.AuthorizationEndpoint != "" {
if ar.authorizationEndpoint, err = url.Parse(pd.WellKnown.AuthorizationEndpoint); err != nil {
providerLogger.WithError(err).Errorln("failed to parse oidc provider discover document authorization_endpoint")
}
}
if pd.WellKnown != nil && pd.WellKnown.EndSessionEndpoint != "" {
if ar.endSessionEndpoint, err = url.Parse(pd.WellKnown.EndSessionEndpoint); err != nil {
providerLogger.WithError(err).Errorln("failed to parse oidc provider discover document endsession_endpoint")
}
}
if pd.WellKnown != nil && pd.WellKnown.TokenEndpoint != "" {
if ar.tokenEndpoint, err = url.Parse(pd.WellKnown.TokenEndpoint); err != nil {
providerLogger.WithError(err).Errorln("failed to parse oidc provider discover document token_endpoint")
}
}
if pd.WellKnown != nil && pd.WellKnown.UserInfoEndpoint != "" {
if ar.userInfoEndpoint, err = url.Parse(pd.WellKnown.UserInfoEndpoint); err != nil {
providerLogger.WithError(err).Errorln("failed to parse oidc provider discover document userinfo_endpoint")
}
}
if pd.JWKS != jwks {
if err := ar.setValidationKeysFromJWKS(pd.JWKS, true); err != nil {
providerLogger.Errorf("failed to set authority keys from oidc provider jwks: %v", err)
}
}
if pd.WellKnown != nil {
ar.wellKnown = pd.WellKnown
}
ready := ar.ready
if ar.authorizationEndpoint != nil && ar.validationKeys != nil {
ar.ready = true
} else {
ar.ready = false
}
if ready != ar.ready {
if ar.ready {
providerLogger.Infoln("authority is now ready")
} else {
providerLogger.Warnln("authority is no longer ready")
}
} else if !ar.ready {
providerLogger.Warnln("authority not ready")
}
ar.mutex.Unlock()
}
}
}()
return nil
}
@@ -0,0 +1,225 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package authorities
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/url"
"sync"
"github.com/sirupsen/logrus"
"sigs.k8s.io/yaml"
)
// Registry implements the registry for registered authorities.
type Registry struct {
mutex sync.RWMutex
baseURI *url.URL
defaultID string
authorities map[string]AuthorityRegistration
logger logrus.FieldLogger
}
// NewRegistry creates a new authorizations Registry with the provided parameters.
func NewRegistry(ctx context.Context, baseURI *url.URL, registrationConfFilepath string, logger logrus.FieldLogger) (*Registry, error) {
registryData := &authorityRegistryData{}
if registrationConfFilepath != "" {
logger.Debugf("parsing authorities registration conf from %v", registrationConfFilepath)
registryFile, err := ioutil.ReadFile(registrationConfFilepath)
if err != nil {
return nil, err
}
// NOTE(longsleep): Convert YAML to JSON for parsing. This is done to be
// able to use jose.JSONWebKeySet custom JSON unmarshaller which is not
// available for YAML. We could use sigs.k8s.io/yaml directly as it has
// the same behavior, but we do it explicitly to be clear.
j, err := yaml.YAMLToJSON(registryFile)
if err != nil {
return nil, err
}
err = json.Unmarshal(j, registryData)
if err != nil {
return nil, err
}
}
r := &Registry{
baseURI: baseURI,
authorities: make(map[string]AuthorityRegistration),
logger: logger,
}
var defaultAuthorityRegistrationData *authorityRegistrationData
var defaultAuthority AuthorityRegistration
for _, registrationData := range registryData.Authorities {
var authority AuthorityRegistration
var validateErr error
if registrationData.ID == "" {
registrationData.ID = registrationData.Name
r.logger.WithField("id", registrationData.ID).Warnln("authority has no id, using name")
}
switch registrationData.AuthorityType {
case AuthorityTypeOIDC:
authority, validateErr = newOIDCAuthorityRegistration(r, registrationData)
case AuthorityTypeSAML2:
authority, validateErr = newSAML2AuthorityRegistration(r, registrationData)
}
fields := logrus.Fields{
"id": registrationData.ID,
"authority_type": registrationData.AuthorityType,
"insecure": registrationData.Insecure,
"trusted": registrationData.Trusted,
"default": registrationData.Default,
"alias_required": registrationData.IdentityAliasRequired,
}
if validateErr != nil {
logger.WithError(validateErr).WithFields(fields).Warnln("skipped registration of invalid authority entry")
continue
}
if authority == nil {
logger.WithFields(fields).Warnln("skipped registration of authority of unknown type")
continue
}
if registerErr := r.Register(authority); registerErr != nil {
logger.WithError(registerErr).WithFields(fields).Warnln("skipped registration of invalid authority")
continue
}
if registrationData.Default || defaultAuthorityRegistrationData == nil {
if defaultAuthorityRegistrationData == nil || !defaultAuthorityRegistrationData.Default {
defaultAuthorityRegistrationData = registrationData
defaultAuthority = authority
} else {
logger.Warnln("ignored default authority flag since already have a default")
}
} else {
// TODO(longsleep): Implement authority selection.
logger.Warnln("non-default additional authorities are not supported yet")
}
go func() {
if initializeErr := authority.Initialize(ctx, r); initializeErr != nil {
logger.WithError(initializeErr).WithFields(fields).Warnln("failed to initialize authority")
}
}()
logger.WithFields(fields).Debugln("registered authority")
}
if defaultAuthority != nil {
if defaultAuthorityRegistrationData.Default {
r.defaultID = defaultAuthorityRegistrationData.ID
logger.WithField("id", defaultAuthorityRegistrationData.ID).Infoln("using external default authority")
} else {
logger.Warnln("non-default authorities are not supported yet")
}
}
return r, nil
}
// Register validates the provided authority registration and adds the authority
// to the accociated registry if valid. Returns error otherwise.
func (r *Registry) Register(authority AuthorityRegistration) error {
id := authority.ID()
if id == "" {
return errors.New("no authority id")
}
if err := authority.Validate(); err != nil {
return fmt.Errorf("authority data validation error: %w", err)
}
switch authority.AuthorityType() {
case AuthorityTypeOIDC:
// breaks
case AuthorityTypeSAML2:
// breaks
default:
return fmt.Errorf("unknown authority type: %v", authority.AuthorityType())
}
r.mutex.Lock()
defer r.mutex.Unlock()
r.authorities[id] = authority
return nil
}
// Lookup returns and validates the authority Detail information for the provided
// parameters from the accociated authority registry.
func (r *Registry) Lookup(ctx context.Context, authorityID string) (*Details, error) {
registration, ok := r.Get(ctx, authorityID)
if !ok {
return nil, fmt.Errorf("unknown authority id: %v", authorityID)
}
details := registration.Authority()
return details, nil
}
// Get returns the registered authorities registration for the provided client ID.
func (r *Registry) Get(ctx context.Context, authorityID string) (AuthorityRegistration, bool) {
if authorityID == "" {
return nil, false
}
// Lookup authority registration.
r.mutex.RLock()
registration, ok := r.authorities[authorityID]
r.mutex.RUnlock()
return registration, ok
}
// Find returns the first registered authority that satisfies the provided
// selector function.
func (r *Registry) Find(ctx context.Context, selector func(authority AuthorityRegistration) bool) (AuthorityRegistration, bool) {
r.mutex.RLock()
defer r.mutex.RUnlock()
for _, authority := range r.authorities {
if selector(authority) {
return authority, true
}
}
return nil, false
}
// Default returns the default authority from the associated registry if any.
func (r *Registry) Default(ctx context.Context) *Details {
authority, _ := r.Lookup(ctx, r.defaultID)
return authority
}
+650
View File
@@ -0,0 +1,650 @@
/*
* Copyright 2017-2020 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package authorities
import (
"context"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/crewjam/httperr"
"github.com/crewjam/saml"
"github.com/crewjam/saml/samlsp"
dsig "github.com/russellhaering/goxmldsig"
"github.com/sirupsen/logrus"
"github.com/libregraph/lico/identity/authorities/samlext"
"github.com/libregraph/lico/utils"
)
var cleanWhitespaceRegexp = regexp.MustCompile(`\s+`)
type saml2AuthorityRegistration struct {
registry *Registry
data *authorityRegistrationData
discover bool
metadataEndpoint *url.URL
mutex sync.RWMutex
ready bool
serviceProvider *saml.ServiceProvider
serviceProviderSigningCerts []*x509.Certificate
}
func newSAML2AuthorityRegistration(registry *Registry, registrationData *authorityRegistrationData) (*saml2AuthorityRegistration, error) {
ar := &saml2AuthorityRegistration{
registry: registry,
data: registrationData,
}
if ar.data.RawMetadataEndpoint != "" {
if u, err := url.Parse(ar.data.RawMetadataEndpoint); err == nil {
ar.metadataEndpoint = u
} else {
return nil, fmt.Errorf("invalid metadata_endpoint value: %w", err)
}
}
if ar.data.EntityID == "" {
baseURIString := registry.baseURI.String()
metadataURI, _ := url.Parse(baseURIString + "/identifier/saml2/metadata") // Use our own meta data
ar.data.EntityID = metadataURI.String()
}
if ar.data.Discover != nil {
ar.discover = *ar.data.Discover
}
if !ar.discover {
return nil, errors.New("saml2 must use discover")
}
return ar, nil
}
func (ar *saml2AuthorityRegistration) ID() string {
return ar.data.ID
}
func (ar *saml2AuthorityRegistration) Name() string {
return ar.data.Name
}
func (ar *saml2AuthorityRegistration) AuthorityType() string {
return ar.data.AuthorityType
}
func (ar *saml2AuthorityRegistration) Authority() *Details {
details := &Details{
ID: ar.data.ID,
Name: ar.data.Name,
AuthorityType: ar.data.AuthorityType,
Trusted: ar.data.Trusted,
Insecure: ar.data.Insecure,
EndSessionEnabled: ar.data.EndSessionEnabled,
registration: ar,
}
ar.mutex.RLock()
details.ready = ar.ready
ar.mutex.RUnlock()
return details
}
func (ar *saml2AuthorityRegistration) Issuer() string {
issuer := ar.serviceProvider.IDPMetadata.EntityID
if issuer == "" {
issuer = ar.metadataEndpoint.String()
}
return issuer
}
func (ar *saml2AuthorityRegistration) Validate() error {
return nil
}
func (ar *saml2AuthorityRegistration) Initialize(ctx context.Context, registry *Registry) error {
ar.mutex.Lock()
defer ar.mutex.Unlock()
if ar.metadataEndpoint == nil {
return fmt.Errorf("no metadata_endpoint set")
}
if ar.data.EntityID == "" {
return fmt.Errorf("no entity_id set")
}
logger := registry.logger.WithFields(logrus.Fields{
"id": ar.data.ID,
"type": AuthorityTypeSAML2,
})
var client *http.Client
if ar.data.Insecure {
client = utils.InsecureHTTPClient
} else {
client = utils.DefaultHTTPClient
}
baseURIString := registry.baseURI.String()
acsURL, _ := url.Parse(baseURIString + "/identifier/saml2/acs") // Assertion Consumer Service
sloURL, _ := url.Parse(baseURIString + "/identifier/_/saml2/slo") // Single Logout Service
go func() {
var md *saml.EntityDescriptor
var err error
for {
logger.Debugf("fetching SAML2 provider meta data: %s", ar.metadataEndpoint.String())
md, err = func() (*saml.EntityDescriptor, error) {
req, fetchErr := http.NewRequest(http.MethodGet, ar.metadataEndpoint.String(), nil)
if fetchErr != nil {
return nil, fetchErr
}
req = req.WithContext(ctx)
req.Header.Set("User-Agent", utils.DefaultHTTPUserAgent)
resp, fetchErr := client.Do(req)
if fetchErr != nil {
return nil, fetchErr
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, httperr.Response(*resp)
}
data, fetchErr := ioutil.ReadAll(resp.Body)
if fetchErr != nil {
return nil, fetchErr
}
return samlsp.ParseMetadata(data)
}()
if err != nil {
logger.WithError(err).Errorln("error while saml2 provider meta data update")
}
select {
case <-ctx.Done():
return
default:
}
if md != nil {
for {
var serviceProviderSigningCerts []*x509.Certificate
serviceProviderSigningCerts, err = getCertsFromMetadata(md, "signing")
if err != nil {
break
}
if len(serviceProviderSigningCerts) == 0 {
err = errors.New("no signing certificate in meta data")
break
}
ar.mutex.Lock()
ar.serviceProviderSigningCerts = serviceProviderSigningCerts
ar.serviceProvider = &saml.ServiceProvider{
EntityID: ar.data.EntityID,
AcsURL: *acsURL,
SloURL: *sloURL,
IDPMetadata: md,
AllowIDPInitiated: false,
AuthnNameIDFormat: saml.TransientNameIDFormat,
}
ready := ar.ready
if ar.serviceProvider != nil {
ar.ready = true
} else {
ar.ready = false
}
if ready != ar.ready {
if ar.ready {
logger.Infoln("authority is now ready")
} else {
logger.Warnln("authority is no longer ready")
}
} else if !ar.ready {
logger.Warnln("authority not ready")
}
ready = ar.ready
ar.mutex.Unlock()
if ready {
logger.WithFields(logrus.Fields{
"signing_certs": len(serviceProviderSigningCerts),
"issuer": ar.Issuer(),
}).Debugln("SAML2 provider meta data loaded and initialized")
return
}
break
}
if err != nil {
logger.WithError(err).Errorln("error while initializing saml2 provider from meta data")
}
}
select {
case <-ctx.Done():
return
case <-time.After(30 * time.Second):
// breaks
}
}
}()
return nil
}
func (ar *saml2AuthorityRegistration) IdentityClaimValue(rawAssertion interface{}) (string, map[string]interface{}, error) {
assertion, _ := rawAssertion.(*saml.Assertion)
if assertion == nil {
return "", nil, errors.New("invalid assertion data")
}
icn := ar.data.IdentityClaimName
if icn == "" {
icn = "uid" // TODO(longsleep): Use constant.
}
var cvs string
var ok bool
for _, attributeStatement := range assertion.AttributeStatements {
for _, attr := range attributeStatement.Attributes {
values := []string{}
for _, value := range attr.Values {
values = append(values, value.Value)
}
ar.registry.logger.WithFields(logrus.Fields{
"FriendlyName": attr.FriendlyName,
"Name": attr.Name,
"NameFormat": attr.NameFormat,
"Values": values,
}).Debugln("saml2 attributeStatement")
if !ok {
claimName := attr.FriendlyName
if claimName == "" {
claimName = attr.Name
}
if claimName == icn && len(values) == 1 {
if attr.NameFormat != "urn:oasis:names:tc:SAML:2.0:attrname-format:basic" {
ar.registry.logger.WithField("NameFormat", attr.NameFormat).Warnln("saml2 ignoring unsupported name format for identity claim name")
continue
}
cvs = values[0]
ok = true
}
}
}
}
if !ok {
return "", nil, errors.New("identity claim not found")
}
// Add extra external authority claims, for example SessionIndex.
claims := make(map[string]interface{})
for _, authnStatement := range assertion.AuthnStatements {
ar.registry.logger.WithFields(logrus.Fields{
"SessionNotOnOrAfter": authnStatement.SessionNotOnOrAfter,
"SessionIndex": authnStatement.SessionIndex,
}).Debugln("saml2 authnStatement")
if authnStatement.SessionIndex != "" {
claims["SessionIndex"] = authnStatement.SessionIndex
if authnStatement.SessionNotOnOrAfter != nil {
if saml.TimeNow().After(*authnStatement.SessionNotOnOrAfter) {
return "", nil, errors.New("session is expired")
}
claims["SessionNotOnOrAfter"] = authnStatement.SessionNotOnOrAfter
}
}
}
if assertion.Subject != nil {
switch assertion.Subject.NameID.Format {
case string(saml.TransientNameIDFormat):
claims["TransientNameID"] = assertion.Subject.NameID.Value
case string(saml.PersistentNameIDFormat):
claims["PersistentNameID"] = assertion.Subject.NameID.Value
case string(saml.UnspecifiedNameIDFormat):
claims["UnspecifiedNameID"] = assertion.Subject.NameID.Value
default:
return "", nil, errors.New("nameid format must be transient")
}
} else {
return "", nil, errors.New("subject not found")
}
// Convert claim value.
whitelisted := false
if ar.data.IdentityAliases != nil {
if alias, ok := ar.data.IdentityAliases[cvs]; ok && alias != "" {
cvs = alias
whitelisted = true
}
}
// Check whitelist.
if ar.data.IdentityAliasRequired && !whitelisted {
return "", nil, errors.New("identity claim has no alias")
}
return cvs, claims, nil
}
func (ar *saml2AuthorityRegistration) MakeRedirectAuthenticationRequestURL(state string) (*url.URL, map[string]interface{}, error) {
ar.mutex.RLock()
defer ar.mutex.RUnlock()
if !ar.ready {
return nil, nil, errors.New("not ready")
}
authReq, err := ar.serviceProvider.MakeAuthenticationRequest(ar.serviceProvider.GetSSOBindingLocation(saml.HTTPRedirectBinding), saml.HTTPRedirectBinding, saml.HTTPPostBinding)
if err != nil {
return nil, nil, err
}
uri, err := authReq.Redirect(state, ar.serviceProvider)
if err != nil {
return nil, nil, err
}
return uri, map[string]interface{}{
"rid": authReq.ID,
}, nil
}
func (ar *saml2AuthorityRegistration) MakeRedirectEndSessionRequestURL(ref interface{}, state string) (*url.URL, map[string]interface{}, error) {
ar.mutex.RLock()
defer ar.mutex.RUnlock()
if !ar.ready {
return nil, nil, errors.New("not ready")
}
logonRef := ref.(*string)
var nameID string
var nameIDFormat saml.NameIDFormat
if logonRef != nil {
logonRefParts := strings.SplitN(*logonRef, ":", 2)
switch logonRefParts[0] {
case "transient":
nameIDFormat = saml.TransientNameIDFormat
case "persistent":
nameIDFormat = saml.PersistentNameIDFormat
case "unspecified":
nameIDFormat = saml.UnspecifiedNameIDFormat
default:
return nil, nil, fmt.Errorf("unsupported name id format prefix: %v", logonRefParts[0])
}
nameID = logonRefParts[1]
}
req, err := ar.serviceProvider.MakeLogoutRequest(ar.serviceProvider.GetSLOBindingLocation(saml.HTTPRedirectBinding), nameID)
if err != nil {
return nil, nil, fmt.Errorf("failed to make redirect logout request: %w", err)
}
req.NameID.Format = string(nameIDFormat)
lor := &samlext.LogoutRequest{
LogoutRequest: req,
}
return lor.Redirect(state), nil, nil
}
func (ar *saml2AuthorityRegistration) MakeRedirectEndSessionResponseURL(rawReq interface{}, state string) (*url.URL, map[string]interface{}, error) {
ar.mutex.RLock()
defer ar.mutex.RUnlock()
if !ar.ready {
return nil, nil, errors.New("not ready")
}
req, _ := rawReq.(*saml.LogoutRequest)
if req == nil {
return nil, nil, errors.New("invalid request data")
}
// NOTE(longsleep): This resonse currently always reports success.
status := &saml.Status{
StatusCode: saml.StatusCode{
Value: saml.StatusSuccess,
},
}
res, err := samlext.MakeLogoutResponse(ar.serviceProvider, req, status, saml.HTTPRedirectBinding)
if err != nil {
return nil, nil, fmt.Errorf("failed to make logout response: %w", err)
}
uri := res.Redirect(state)
return uri, nil, nil
}
func (ar *saml2AuthorityRegistration) ParseStateResponse(req *http.Request, state string, extra map[string]interface{}) (interface{}, error) {
requestID := extra["rid"].(string)
return ar.serviceProvider.ParseResponse(req, []string{requestID})
}
func (ar *saml2AuthorityRegistration) ValidateIdpEndSessionRequest(req interface{}, state string) (bool, error) {
slo := req.(*samlext.IdpLogoutRequest)
if slo.Request == nil {
return false, fmt.Errorf("request not set")
}
// NOTE(longsleep): We currently only support redirect binding (which uses a detached signature).
if slo.Binding != saml.HTTPRedirectBinding {
return false, fmt.Errorf("binding not supported")
}
// Only validate signature if signed.
if slo.SigAlg == nil {
return false, nil
}
ar.mutex.RLock()
serviceProviderSigningCerts := ar.serviceProviderSigningCerts
ready := ar.ready
ar.mutex.RUnlock()
if !ready {
return false, errors.New("not ready")
}
if len(serviceProviderSigningCerts) == 0 {
// No signing certs, cannot do anything.
return false, nil
}
// Check if we are good.
switch *slo.SigAlg {
case dsig.RSASHA1SignatureMethod:
ar.registry.logger.WithField("sig_alg", *slo.SigAlg).Warnln("saml2 insecure signature alg in idp logout request")
if !ar.Authority().Insecure {
return false, nil
}
default:
// Let the rest pass, and decide later.
}
if len(slo.Signature) == 0 {
return true, fmt.Errorf("signature data is empty")
}
// Get first certificate, and verify.
if len(serviceProviderSigningCerts) > 1 {
ar.registry.logger.Warnln("saml2 authority has multiple signing keys, using first")
}
pubKey := serviceProviderSigningCerts[0].PublicKey
if verifyErr := slo.VerifySignature(pubKey); verifyErr != nil {
return true, fmt.Errorf("signature verification failed: %w", verifyErr)
}
return true, nil
}
func (ar *saml2AuthorityRegistration) ValidateIdpEndSessionResponse(res interface{}, state string) (bool, error) {
lor := res.(*samlext.IdpLogoutResponse)
if lor.Response == nil {
return false, fmt.Errorf("response not set")
}
// NOTE(longsleep): We currently only support redirect binding (which uses a detached signature).
if lor.Binding != saml.HTTPRedirectBinding {
return false, fmt.Errorf("binding not supported")
}
// Only validate signature if signed.
if lor.SigAlg == nil {
return false, nil
}
ar.mutex.RLock()
serviceProviderSigningCerts := ar.serviceProviderSigningCerts
ready := ar.ready
ar.mutex.RUnlock()
if !ready {
return false, errors.New("not ready")
}
if len(serviceProviderSigningCerts) == 0 {
// No signing certs, cannot do anything.
return false, nil
}
// Check if we are good.
switch *lor.SigAlg {
case dsig.RSASHA1SignatureMethod:
ar.registry.logger.WithField("sig_alg", *lor.SigAlg).Warnln("saml2 insecure signature alg in idp logout response")
if !ar.Authority().Insecure {
return false, nil
}
default:
// Let the rest pass, and decide later.
}
if len(lor.Signature) == 0 {
return true, fmt.Errorf("signature data is empty")
}
// Get first certificate, and verify.
if len(serviceProviderSigningCerts) > 1 {
ar.registry.logger.Warnln("saml2 authority has multiple signing keys, using first")
}
pubKey := serviceProviderSigningCerts[0].PublicKey
if verifyErr := lor.VerifySignature(pubKey); verifyErr != nil {
return true, fmt.Errorf("signature verification failed: %w", verifyErr)
}
return true, nil
}
func (ar *saml2AuthorityRegistration) Metadata() AuthorityMetadata {
ar.mutex.RLock()
sp := ar.serviceProvider
ar.mutex.RUnlock()
if sp == nil {
return nil
}
metadata := sp.Metadata()
// Set SLO to use redirect binding.
metadata.SPSSODescriptors[0].SSODescriptor.SingleLogoutServices = []saml.Endpoint{
{
Binding: saml.HTTPRedirectBinding,
Location: sp.SloURL.String(),
},
}
return metadata
}
func getCertsFromMetadata(md *saml.EntityDescriptor, use string) ([]*x509.Certificate, error) {
var certStrs []string
for _, idpSSODescriptor := range md.IDPSSODescriptors {
for _, keyDescriptor := range idpSSODescriptor.KeyDescriptors {
if keyDescriptor.Use == use {
for _, cert := range keyDescriptor.KeyInfo.X509Data.X509Certificates {
if cert.Data != "" {
certStrs = append(certStrs, cert.Data)
}
}
}
}
}
// If there are no explicitly signing certs, just return the first non-empty cert we find.
if len(certStrs) == 0 {
for _, idpSSODescriptor := range md.IDPSSODescriptors {
for _, keyDescriptor := range idpSSODescriptor.KeyDescriptors {
if keyDescriptor.Use == "" {
for _, cert := range keyDescriptor.KeyInfo.X509Data.X509Certificates {
if cert.Data != "" {
certStrs = append(certStrs, cert.Data)
}
}
break
}
}
}
}
var certs []*x509.Certificate
for _, certStr := range certStrs {
certStr = cleanWhitespaceRegexp.ReplaceAllString(certStr, "")
certBytes, err := base64.StdEncoding.DecodeString(certStr)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
parsedCert, err := x509.ParseCertificate(certBytes)
if err != nil {
return nil, err
}
certs = append(certs, parsedCert)
}
return certs, nil
}
@@ -0,0 +1,108 @@
/*
* Copyright 2020 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package samlext
import (
"crypto"
"crypto/rsa"
_ "crypto/sha1" // Import all supported hashers.
_ "crypto/sha256"
_ "crypto/sha512"
"fmt"
"strings"
dsig "github.com/russellhaering/goxmldsig"
)
// VerifySignedHTTPRedirectQuery implements validation for signed SAML HTTP
// redirect binding parameters provides via URL query.
func VerifySignedHTTPRedirectQuery(kind string, rawQuery string, sigAlg string, signature []byte, pubKey crypto.PublicKey) error {
var hasher crypto.Hash
// Validate signature.
switch sigAlg {
case dsig.RSASHA1SignatureMethod:
hasher = crypto.SHA1
case dsig.RSASHA256SignatureMethod:
hasher = crypto.SHA256
case dsig.RSASHA512SignatureMethod:
hasher = crypto.SHA512
default:
return fmt.Errorf("unsupported sig alg: %v", sigAlg)
}
if len(signature) == 0 {
return fmt.Errorf("signature data is empty")
}
// The signed data format goes like this:
// SAMLRequest=urlencode(base64(deflate($xml)))&RelayState=urlencode($(relay_state))&SigAlg=urlencode($sig_alg)
// We rebuild it ourselves from the raw request, to avoid differences when url decoding/encoding.
signedQuery := func(query string) string {
m := make(map[string]string)
for query != "" {
key := query
if i := strings.IndexAny(key, "&;"); i >= 0 {
key, query = key[:i], key[i+1:]
} else {
query = ""
}
if key == "" {
continue
}
value := ""
if i := strings.Index(key, "="); i >= 0 {
key, value = key[:i], key[i+1:]
}
m[key] = value // Support only one value, but thats ok since we only want one and if really someone signed multiple values then its fine to fail.
}
s := new(strings.Builder)
for idx, key := range []string{kind, "RelayState", "SigAlg"} {
if value, ok := m[key]; ok {
if idx > 0 {
s.WriteString("&")
}
s.WriteString(key)
s.WriteString("=")
s.WriteString(value)
}
}
return s.String()
}(rawQuery)
// Create hash for the alg.
hash := hasher.New()
if _, hashErr := hash.Write([]byte(signedQuery)); hashErr != nil {
return fmt.Errorf("failed to hash: %w", hashErr)
}
hashed := hash.Sum(nil)
rsaPubKey, ok := pubKey.(*rsa.PublicKey)
if !ok {
return fmt.Errorf("invalid RSA public key")
}
// NOTE(longsleep): All sig algs above, use PKCS1v15 with RSA.
if verifyErr := rsa.VerifyPKCS1v15(rsaPubKey, hasher, hashed, signature); verifyErr != nil {
return fmt.Errorf("signature verification failed: %w", verifyErr)
}
return nil
}
@@ -0,0 +1,125 @@
/*
* Copyright 2017-2020 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package samlext
import (
"bytes"
"compress/flate"
"crypto"
"encoding/base64"
"encoding/xml"
"fmt"
"io/ioutil"
"net/http"
"time"
"github.com/crewjam/saml"
)
// IdpLogoutRequest is used by IdentityProvider to handle a single logout request.
type IdpLogoutRequest struct {
HTTPRequest *http.Request
Binding string
RequestBuffer []byte
Request *saml.LogoutRequest
Now time.Time
RelayState string
SigAlg *string
Signature []byte
}
func NewIdpLogoutRequest(r *http.Request) (*IdpLogoutRequest, error) {
req := &IdpLogoutRequest{
HTTPRequest: r,
Now: saml.TimeNow(),
}
switch r.Method {
case http.MethodGet:
req.Binding = saml.HTTPRedirectBinding
compressedRequest, err := base64.StdEncoding.DecodeString(r.URL.Query().Get("SAMLRequest"))
if err != nil {
return nil, fmt.Errorf("cannot decode request: %w", err)
}
req.RequestBuffer, err = ioutil.ReadAll(flate.NewReader(bytes.NewReader(compressedRequest)))
if err != nil {
return nil, fmt.Errorf("cannot decompress request: %w", err)
}
req.RelayState = r.URL.Query().Get("RelayState")
sigAlgRaw := r.URL.Query().Get("SigAlg")
if sigAlgRaw != "" {
req.SigAlg = &sigAlgRaw
signature, err := base64.StdEncoding.DecodeString(r.URL.Query().Get("Signature"))
if err != nil {
return nil, fmt.Errorf("cannot decode signature: %w", err)
}
req.Signature = signature
}
case http.MethodPost:
if err := r.ParseForm(); err != nil {
return nil, err
}
req.Binding = saml.HTTPPostBinding
var err error
req.RequestBuffer, err = base64.StdEncoding.DecodeString(r.PostForm.Get("SAMLRequest"))
if err != nil {
return nil, err
}
req.RelayState = r.PostForm.Get("RelayState")
return nil, fmt.Errorf("parsing logout request from POST is not implemented")
default:
return nil, fmt.Errorf("method not allowed")
}
return req, nil
}
// Validate checks that the authentication request is valid and assigns
// the LogoutRequest and Metadata properties. Returns a non-nil error if the
// request is not valid.
func (req *IdpLogoutRequest) Validate() error {
request := &saml.LogoutRequest{}
if err := xml.Unmarshal(req.RequestBuffer, request); err != nil {
return err
}
req.Request = request
if req.Request.IssueInstant.Add(saml.MaxIssueDelay).Before(req.Now) {
return fmt.Errorf("request expired at %s", req.Request.IssueInstant.Add(saml.MaxIssueDelay))
}
if req.Request.Version != "2.0" {
return fmt.Errorf("expected SAML request version 2.0 got %v", req.Request.Version)
}
return nil
}
// VerifySignature verifies the associated IdpLogoutRequest data with the
// associated Signature using the provided public key.
func (req *IdpLogoutRequest) VerifySignature(pubKey crypto.PublicKey) error {
return VerifySignedHTTPRedirectQuery("SAMLRequest", req.HTTPRequest.URL.RawQuery, *req.SigAlg, req.Signature, pubKey)
}
@@ -0,0 +1,126 @@
/*
* Copyright 2017-2020 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package samlext
import (
"bytes"
"compress/flate"
"crypto"
"encoding/base64"
"encoding/xml"
"fmt"
"io/ioutil"
"net/http"
"time"
"github.com/crewjam/saml"
)
// IdpLogoutResponse is used by IdentityProvider to handle a single logout
// response callbacks.
type IdpLogoutResponse struct {
HTTPRequest *http.Request
Binding string
ResponseBuffer []byte
Response *saml.LogoutResponse
Now time.Time
RelayState string
SigAlg *string
Signature []byte
}
func NewIdpLogoutResponse(r *http.Request) (*IdpLogoutResponse, error) {
res := &IdpLogoutResponse{
HTTPRequest: r,
Now: saml.TimeNow(),
}
switch r.Method {
case http.MethodGet:
res.Binding = saml.HTTPRedirectBinding
compressedResponse, err := base64.StdEncoding.DecodeString(r.URL.Query().Get("SAMLResponse"))
if err != nil {
return nil, fmt.Errorf("cannot decode response: %w", err)
}
res.ResponseBuffer, err = ioutil.ReadAll(flate.NewReader(bytes.NewReader(compressedResponse)))
if err != nil {
return nil, fmt.Errorf("cannot decompress response: %w", err)
}
res.RelayState = r.URL.Query().Get("RelayState")
sigAlgRaw := r.URL.Query().Get("SigAlg")
if sigAlgRaw != "" {
res.SigAlg = &sigAlgRaw
signature, err := base64.StdEncoding.DecodeString(r.URL.Query().Get("Signature"))
if err != nil {
return nil, fmt.Errorf("cannot decode signature: %w", err)
}
res.Signature = signature
}
case http.MethodPost:
if err := r.ParseForm(); err != nil {
return nil, err
}
res.Binding = saml.HTTPPostBinding
var err error
res.ResponseBuffer, err = base64.StdEncoding.DecodeString(r.PostForm.Get("SAMLResponse"))
if err != nil {
return nil, err
}
res.RelayState = r.PostForm.Get("RelayState")
return nil, fmt.Errorf("parsing logout response from POST is not implemented")
default:
return nil, fmt.Errorf("method not allowed")
}
return res, nil
}
// Validate checks that the associated response is valid and assigns
// the LogoutResponse and Metadata properties. Returns a non-nil error if the
// request is not valid.
func (res *IdpLogoutResponse) Validate() error {
response := &saml.LogoutResponse{}
if err := xml.Unmarshal(res.ResponseBuffer, response); err != nil {
return err
}
res.Response = response
if res.Response.IssueInstant.Add(saml.MaxIssueDelay).Before(res.Now) {
return fmt.Errorf("response expired at %s", res.Response.IssueInstant.Add(saml.MaxIssueDelay))
}
if res.Response.Version != "2.0" {
return fmt.Errorf("expected SAML response version 2.0 got %v", res.Response.Version)
}
return nil
}
// VerifySignature verifies the associated IdpLogoutResponse data with the
// associated Signature using the provided public key.
func (res *IdpLogoutResponse) VerifySignature(pubKey crypto.PublicKey) error {
return VerifySignedHTTPRedirectQuery("SAMLResponse", res.HTTPRequest.URL.RawQuery, *res.SigAlg, res.Signature, pubKey)
}
@@ -0,0 +1,57 @@
/*
* Copyright 2017-2020 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package samlext
import (
"bytes"
"compress/flate"
"encoding/base64"
"net/url"
"github.com/beevik/etree"
"github.com/crewjam/saml"
)
type LogoutRequest struct {
*saml.LogoutRequest
}
// Redirect returns a URL suitable for using the redirect binding with the response.
func (req *LogoutRequest) Redirect(relayState string) *url.URL {
w := &bytes.Buffer{}
w1 := base64.NewEncoder(base64.StdEncoding, w)
w2, _ := flate.NewWriter(w1, 9)
doc := etree.NewDocument()
doc.SetRoot(req.Element())
if _, err := doc.WriteTo(w2); err != nil {
panic(err)
}
w2.Close()
w1.Close()
rv, _ := url.Parse(req.Destination)
query := rv.Query()
query.Set("SAMLRequest", w.String())
if relayState != "" {
query.Set("RelayState", relayState)
}
rv.RawQuery = query.Encode()
return rv
}
@@ -0,0 +1,88 @@
/*
* Copyright 2017-2020 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package samlext
import (
"bytes"
"compress/flate"
"encoding/base64"
"encoding/xml"
"fmt"
"net/url"
"github.com/crewjam/saml"
"github.com/longsleep/rndm"
)
func MakeLogoutResponse(sp *saml.ServiceProvider, req *saml.LogoutRequest, status *saml.Status, binding string) (*LogoutResponse, error) {
res := &LogoutResponse{&saml.LogoutResponse{
ID: fmt.Sprintf("id-%x", rndm.GenerateRandomBytes(20)),
InResponseTo: req.ID,
Version: "2.0",
IssueInstant: saml.TimeNow(),
Destination: sp.GetSLOBindingLocation(binding),
Issuer: &saml.Issuer{
Format: "urn:oasis:names:tc:SAML:2.0:nameid-format:entity",
Value: firstSet(sp.EntityID, sp.MetadataURL.String()),
},
}}
if status != nil {
res.LogoutResponse.Status = *status
}
return res, nil
}
func firstSet(a, b string) string {
if a == "" {
return b
}
return a
}
type LogoutResponse struct {
*saml.LogoutResponse
}
// Redirect returns a URL suitable for using the redirect binding with the response.
func (res *LogoutResponse) Redirect(relayState string) *url.URL {
w := &bytes.Buffer{}
w1 := base64.NewEncoder(base64.StdEncoding, w)
w2, _ := flate.NewWriter(w1, 9)
e := xml.NewEncoder(w2)
if err := e.Encode(res); err != nil {
panic(err)
}
w2.Close()
w1.Close()
rv, _ := url.Parse(res.Destination)
query := rv.Query()
query.Set("SAMLResponse", w.String())
if relayState != "" {
query.Set("RelayState", relayState)
}
rv.RawQuery = query.Encode()
return rv
}
+129
View File
@@ -0,0 +1,129 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package identity
import (
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/libregraph/lico/oidc/payload"
)
type authRecord struct {
manager Manager
sub string
authorizedScopes map[string]bool
authorizedClaims *payload.ClaimsRequest
claimsByScope map[string]jwt.Claims
user PublicUser
authTime time.Time
}
// NewAuthRecord returns a implementation of identity.AuthRecord holding
// the provided data in memory.
func NewAuthRecord(manager Manager, sub string, authorizedScopes map[string]bool, authorizedClaims *payload.ClaimsRequest, claimsByScope map[string]jwt.Claims) AuthRecord {
if authorizedScopes == nil {
authorizedScopes = make(map[string]bool)
}
return &authRecord{
manager: manager,
sub: sub,
authorizedScopes: authorizedScopes,
authorizedClaims: authorizedClaims,
claimsByScope: claimsByScope,
}
}
// Manager implements the identity.AuthRecord interface, returning the
// accociated identities manager.
func (r *authRecord) Manager() Manager {
return r.manager
}
// Subject implements the identity.AuthRecord interface.
func (r *authRecord) Subject() string {
return r.sub
}
// AuthorizedScopes implements the identity.AuthRecord interface.
func (r *authRecord) AuthorizedScopes() map[string]bool {
return r.authorizedScopes
}
// AuthorizeScopes implements the identity.AuthRecord interface.
func (r *authRecord) AuthorizeScopes(scopes map[string]bool) {
authorizedScopes, unauthorizedScopes := AuthorizeScopes(r.manager, r.User(), scopes)
for scope, grant := range authorizedScopes {
if grant {
r.authorizedScopes[scope] = grant
} else {
delete(r.authorizedScopes, scope)
}
}
for scope := range unauthorizedScopes {
delete(r.authorizedScopes, scope)
}
}
// AuthorizedClaims implements the identity.AuthRecord interface.
func (r *authRecord) AuthorizedClaims() *payload.ClaimsRequest {
return r.authorizedClaims
}
// AuthorizeClaims implements the identity.AuthRecord interface.
func (r *authRecord) AuthorizeClaims(claims *payload.ClaimsRequest) {
r.authorizedClaims = claims
}
// Claims implements the identity.AuthRecord interface.
func (r *authRecord) Claims(scopes ...string) []jwt.Claims {
result := make([]jwt.Claims, len(scopes))
for idx, scope := range scopes {
if claimsForScope, ok := r.claimsByScope[scope]; ok {
result[idx] = claimsForScope
}
}
return result
}
// User implements the identity.AuthRecord interface.
func (r *authRecord) User() PublicUser {
return r.user
}
// SetUser implements the identity.AuthRecord interface.
func (r *authRecord) SetUser(u PublicUser) {
r.user = u
}
// LoggedOn implements the identity.AuthRecord interface
func (r *authRecord) LoggedOn() (bool, time.Time) {
return !r.authTime.IsZero(), r.authTime
}
// SetAuthTime implements the identity.AuthRecord interface.
func (r *authRecord) SetAuthTime(authTime time.Time) {
r.authTime = authTime
}
+29
View File
@@ -0,0 +1,29 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package clients
import (
"github.com/golang-jwt/jwt/v5"
)
// RegistrationClaims are claims used to with dynamic clients.
type RegistrationClaims struct {
jwt.RegisteredClaims
*ClientRegistration
}
+63
View File
@@ -0,0 +1,63 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package clients
import (
"crypto"
"net/url"
)
// Details hold detail information about clients identified by ID.
type Details struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
RedirectURI string `json:"redirect_uri"`
Trusted bool `json:"trusted"`
Registration *ClientRegistration `json:"-"`
}
// A Secured is a client records public key identified by ID.
type Secured struct {
ID string
DisplayName string
ApplicationType string
Kid string
PublicKey crypto.PublicKey
TrustedScopes []string
Registration *ClientRegistration
}
// IsLocalNativeHTTPURI returns true if the provided URI qualifies to be used
// as http redirect URI for a native client.
func IsLocalNativeHTTPURI(uri *url.URL) bool {
if uri.Scheme != "http" {
return false
}
return IsLocalNativeHostURI(uri)
}
// IsLocalNativeHostURI returns true if the provided URI hostname is considered
// as localhost for a native client.
func IsLocalNativeHostURI(uri *url.URL) bool {
hostname := uri.Hostname()
return hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1"
}
+245
View File
@@ -0,0 +1,245 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package clients
import (
"context"
"crypto"
"crypto/subtle"
"encoding/base64"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/longsleep/rndm"
"github.com/mendsley/gojwk"
"golang.org/x/crypto/blake2b"
_ "gopkg.in/yaml.v2" // Make sure we have yaml.
)
// Constat data used with dynamic stateless clients.
const (
DynamicStatelessClientIDPrefix = "dyn."
DynamicStatelessClientStaticSaltV1 = "konnect-client-v1"
)
// RegistryData is the base structur of our client registry configuration file.
type RegistryData struct {
Clients []*ClientRegistration `yaml:"clients,flow"`
}
// ClientRegistration defines a client with its properties.
type ClientRegistration struct {
ID string `yaml:"id" json:"-"`
Secret string `yaml:"secret" json:"-"`
Trusted bool `yaml:"trusted" json:"-"`
TrustedScopes []string `yaml:"trusted_scopes" json:"-"`
Insecure bool `yaml:"insecure" json:"-"`
ImplicitScopes []string `yaml:"implicit_scopes" json:"-"`
Dynamic bool `yaml:"-" json:"-"`
IDIssuedAt time.Time `yaml:"-" json:"-"`
SecretExpiresAt time.Time `yaml:"-" json:"-"`
Contacts []string `yaml:"contacts,flow" json:"contacts,omitempty"`
Name string `yaml:"name" json:"name,omitempty"`
URI string `yaml:"uri" json:"uri,omitempty"`
GrantTypes []string `yaml:"grant_types,flow" json:"grant_types,omitempty"`
ApplicationType string `yaml:"application_type" json:"application_type,omitempty"`
RedirectURIs []string `yaml:"redirect_uris,flow" json:"redirect_uris,omitempty"`
Origins []string `yaml:"origins,flow" json:"-"`
JWKS *gojwk.Key `yaml:"jwks" json:"-"`
RawIDTokenSignedResponseAlg string `yaml:"id_token_signed_response_alg" json:"id_token_signed_response_alg,omitempty"`
RawUserInfoSignedResponseAlg string `yaml:"userinfo_signed_response_alg" json:"userinfo_signed_response_alg,omitempty"`
RawRequestObjectSigningAlg string `yaml:"request_object_signing_alg" json:"request_object_signing_alg,omitempty"`
RawTokenEndpointAuthMethod string `yaml:"token_endpoint_auth_method" json:"token_endpoint_auth_method,omitempty"`
RawTokenEndpointAuthSigningAlg string `yaml:"token_endpoint_auth_signing_alg" json:"token_endpoint_auth_signing_alg,omitempty"`
PostLogoutRedirectURIs []string `yaml:"post_logout_redirect_uris,flow" json:"post_logout_redirect_uris,omitempty"`
}
// Validate validates the associated client registration data and returns error
// if the data is not valid.
func (cr *ClientRegistration) Validate() error {
return nil
}
// Secure looks up the a matching key from the accociated client registration
// and returns its public key part as a secured client.
func (cr *ClientRegistration) Secure(rawKid interface{}) (*Secured, error) {
var kid string
var key crypto.PublicKey
var err error
switch len(cr.JWKS.Keys) {
case 0:
// breaks
case 1:
// Use the one and only, no matter what kid says.
key, err = cr.JWKS.Keys[0].DecodePublicKey()
if err != nil {
return nil, err
}
kid = cr.JWKS.Keys[0].Kid
default:
// Find by kid.
kid, _ = rawKid.(string)
if kid == "" {
kid = "default"
}
for _, k := range cr.JWKS.Keys {
if kid == k.Kid {
key, err = k.DecodePublicKey()
if err != nil {
return nil, err
}
break
}
}
}
if key == nil {
return nil, fmt.Errorf("unknown kid")
}
return &Secured{
ID: cr.ID,
DisplayName: cr.Name,
ApplicationType: cr.ApplicationType,
Kid: kid,
PublicKey: key,
TrustedScopes: cr.TrustedScopes,
Registration: cr,
}, nil
}
// SetDynamic modifieds the required data for the associated client registration
// so it becomes a dynamic client.
func (cr *ClientRegistration) SetDynamic(ctx context.Context, creator func(ctx context.Context, signingMethod jwt.SigningMethod, claims jwt.Claims) (string, error)) error {
if creator == nil {
return fmt.Errorf("no creator")
}
if cr.ID != "" {
return fmt.Errorf("has ID already")
}
registry, ok := FromRegistryContext(ctx)
if !ok {
return fmt.Errorf("no registry")
}
// Initialize basic client registration data for dynamic client.
cr.IDIssuedAt = time.Now()
if registry.dynamicClientSecretDuration > 0 {
cr.SecretExpiresAt = time.Now().Add(registry.dynamicClientSecretDuration)
}
cr.Dynamic = true
sub, secret, err := cr.makeSecret(nil)
if err != nil {
return fmt.Errorf("failed to make dynamic client secret: %v", err)
}
// Stateless Dynamic Client Registration encodes all relevant data in the
// client_id. See https://openid.net/specs/openid-connect-registration-1_0.html#StatelessRegistration
// for more information. We use a JWT as client_id.
claims := &RegistrationClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: sub,
IssuedAt: jwt.NewNumericDate(cr.IDIssuedAt),
ExpiresAt: jwt.NewNumericDate(cr.SecretExpiresAt),
},
ClientRegistration: cr,
}
// Create signed stateless client ID by help of the provided creator function.
id, err := creator(ctx, nil, claims)
if err != nil {
return nil
}
// Fill in ID and secret.
cr.ID = DynamicStatelessClientIDPrefix + id
cr.Secret = secret
return nil
}
// ApplyImplicitScopes apples the associated registration's implicit scopes to
// the provided scopes map.
func (cr *ClientRegistration) ApplyImplicitScopes(scopes map[string]bool) error {
for _, scope := range cr.ImplicitScopes {
if scope != "" {
scopes[scope] = true
}
}
return nil
}
func (cr *ClientRegistration) makeSecret(secret []byte) (string, string, error) {
// Create random secret. HMAC the client name with it to get the subject.
if secret == nil {
secret = rndm.GenerateRandomBytes(64)
}
hasher, err := blake2b.New512(secret)
if err != nil {
return "", "", fmt.Errorf("failed to create hasher for dynamic client_id: %v", err)
}
hasher.Write([]byte(cr.Name))
hasher.Write([]byte(" "))
hasher.Write([]byte(DynamicStatelessClientStaticSaltV1))
sub := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil))
return sub, base64.RawURLEncoding.EncodeToString(secret), nil
}
func (cr *ClientRegistration) validateSecret(clientSecret string) (bool, error) {
if cr.Dynamic {
if cr.Secret == "" {
// Fail fast, since dynamic clients must have a secret.
return false, fmt.Errorf("no secret in registration")
}
// Dynamic clients use hashed passwords.
secret, err := base64.RawURLEncoding.DecodeString(clientSecret)
if err != nil {
return false, fmt.Errorf("failed to decode client secret: %v", err)
}
sub, _, err := cr.makeSecret(secret)
if err != nil {
return false, fmt.Errorf("failed to produce client secret for comparison: %v", err)
}
return subtle.ConstantTimeCompare([]byte(sub), []byte(cr.Secret)) == 1, nil
}
if cr.Secret != "" && subtle.ConstantTimeCompare([]byte(clientSecret), []byte(cr.Secret)) != 1 {
return false, nil
}
return true, nil
}
+368
View File
@@ -0,0 +1,368 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package clients
import (
"context"
"errors"
"fmt"
"io/ioutil"
"net/url"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/libregraph/oidc-go"
"github.com/sirupsen/logrus"
"gopkg.in/yaml.v2"
)
// Registry implements the registry for registered clients.
type Registry struct {
mutex sync.RWMutex
trustedURI *url.URL
clients map[string]*ClientRegistration
allowDynamicClientRegistration bool
dynamicClientSecretDuration time.Duration
StatelessCreator func(ctx context.Context, signingMethod jwt.SigningMethod, claims jwt.Claims) (string, error)
StatelessValidator func(token *jwt.Token) (interface{}, error)
logger logrus.FieldLogger
}
// contextKey is an unexported type for keys defined in this package.
// This prevents collisions with keys defined in other packages.
type contextKey int
// claimsKey is the key for claims in contexts. It is
// unexported; clients use konnect.NewClaimsContext and
// connect.FromClaimsContext instead of using this key directly.
var registryKey contextKey
// NewRegistry created a new client Registry with the provided parameters.
func NewRegistry(ctx context.Context, trustedURI *url.URL, registrationConfFilepath string, allowDynamicClientRegistration bool, dynamicClientSecretDuration time.Duration, logger logrus.FieldLogger) (*Registry, error) {
registryData := &RegistryData{}
if registrationConfFilepath != "" {
logger.Debugf("parsing identifier registration conf from %v", registrationConfFilepath)
registryFile, err := ioutil.ReadFile(registrationConfFilepath)
if err != nil {
return nil, err
}
err = yaml.Unmarshal(registryFile, registryData)
if err != nil {
return nil, err
}
}
r := &Registry{
trustedURI: trustedURI,
clients: make(map[string]*ClientRegistration),
allowDynamicClientRegistration: allowDynamicClientRegistration,
dynamicClientSecretDuration: dynamicClientSecretDuration,
logger: logger,
}
for _, client := range registryData.Clients {
validateErr := client.Validate()
registerErr := r.Register(client)
fields := logrus.Fields{
"client_id": client.ID,
"with_client_secret": client.Secret != "",
"trusted": client.Trusted,
"insecure": client.Insecure,
"application_type": client.ApplicationType,
"redirect_uris": client.RedirectURIs,
"origins": client.Origins,
}
if validateErr != nil {
logger.WithError(validateErr).WithFields(fields).Warnln("skipped registration of invalid client entry")
continue
}
if registerErr != nil {
logger.WithError(registerErr).WithFields(fields).Warnln("skipped registration of invalid client")
continue
}
logger.WithFields(fields).Debugln("registered client")
}
return r, nil
}
// NewRegistryContext returns a new Context that carries value provided Registry.
func NewRegistryContext(ctx context.Context, r *Registry) context.Context {
return context.WithValue(ctx, registryKey, r)
}
// FromRegistryContext returns the Registry value stored in ctx, if any.
func FromRegistryContext(ctx context.Context) (*Registry, bool) {
r, ok := ctx.Value(registryKey).(*Registry)
return r, ok
}
// Register validates the provided client registration and adds the client
// to the accociated registry if valid. Returns error otherwise.
func (r *Registry) Register(client *ClientRegistration) error {
if client.ID == "" {
return errors.New("invalid client_id")
}
if !client.Insecure && len(client.RedirectURIs) == 0 {
return errors.New("no redirect_uris")
}
switch client.ApplicationType {
case "":
client.ApplicationType = oidc.ApplicationTypeWeb
fallthrough
case oidc.ApplicationTypeWeb:
for _, urlString := range client.RedirectURIs {
// http://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata
parsed, _ := url.Parse(urlString)
if parsed == nil || parsed.Host == "" {
return fmt.Errorf("invalid redirect_uri %v - invalid or no hostname", urlString)
} else if !client.Insecure && parsed.Scheme != "https" {
return fmt.Errorf("invalid redirect_uri %v - make sure to use https when application_type is web", parsed)
} else if IsLocalNativeHTTPURI(parsed) {
return fmt.Errorf("invalid redirect_uri %v - host must not be localhost when application_type is web", parsed)
}
if len(client.Origins) == 0 {
// Auto add first redirect scheme and host as Origin if no
// origin is explicitly configured.
client.Origins = append(client.Origins, parsed.Scheme+"://"+parsed.Host)
}
}
if !client.Insecure && len(client.Origins) == 0 {
return errors.New("no origins - origin is required when application_type is web")
}
// breaks
case oidc.ApplicationTypeNative:
// breaks
for _, urlString := range client.RedirectURIs {
// http://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata
parsed, _ := url.Parse(urlString)
if parsed == nil || parsed.Host == "" {
return fmt.Errorf("invalid redirect_uri %v - invalid uri or no hostname", urlString)
} else if parsed.Scheme == "https" {
return fmt.Errorf("invalid redirect_uri %v - scheme must not be https when application_type is native", parsed)
} else if parsed.Scheme == "http" && !IsLocalNativeHTTPURI(parsed) {
return fmt.Errorf("invalid redirect_uri %v = http host must be localhost when application_type is native", parsed)
}
}
// breaks
default:
return fmt.Errorf("unknown application_type: %v", client.ApplicationType)
}
r.mutex.Lock()
defer r.mutex.Unlock()
r.clients[client.ID] = client
return nil
}
// Validate checks if the provided client registration data complies to the
// provided parameters and returns error when it does not.
func (r *Registry) Validate(client *ClientRegistration, clientSecret string, redirectURIString string, originURIString string, withoutSecret bool) error {
if client.ApplicationType == oidc.ApplicationTypeWeb {
if originURIString != "" && (!client.Insecure || len(client.Origins) > 0) {
// Compare originURI if it was given.
originOK := false
for _, urlString := range client.Origins {
if urlString == originURIString {
originOK = true
break
}
}
if !originOK {
return fmt.Errorf("invalid origin: %v", originURIString)
}
}
}
if redirectURIString != "" && (!client.Insecure || len(client.RedirectURIs) > 0) {
// Make sure to validate the redirect URI unless client is marked insecure
// and has no configured redirect URIs.
redirectURIOK := false
for _, registeredURIString := range client.RedirectURIs {
if client.ApplicationType == oidc.ApplicationTypeNative {
registeredURI, _ := url.Parse(registeredURIString)
if IsLocalNativeHTTPURI(registeredURI) {
redirectURI, err := url.Parse(redirectURIString)
if err != nil {
break
}
if IsLocalNativeHTTPURI(redirectURI) {
if registeredURI.Path == "" || redirectURI.Path == registeredURI.Path {
redirectURIOK = true
break
}
}
continue
}
}
if registeredURIString == redirectURIString {
redirectURIOK = true
break
}
}
if !redirectURIOK {
return fmt.Errorf("invalid redirect_uri: %v", redirectURIString)
}
}
if !withoutSecret {
if valid, err := client.validateSecret(clientSecret); !valid {
return fmt.Errorf("invalid client_secret: %v", err)
}
}
return nil
}
// Lookup returns and validates the clients Detail information for the provided
// parameters from the accociated registry.
func (r *Registry) Lookup(ctx context.Context, clientID string, clientSecret string, redirectURI *url.URL, originURIString string, withoutSecret bool) (*Details, error) {
var err error
var trusted bool
var dynamic bool
var displayName string
if clientID == "" {
return nil, fmt.Errorf("invalid client_id")
}
originURI, _ := url.Parse(originURIString)
// Implicit trust for web clients running and redirecting to the same origin
// as the issuer (ourselves).
if r.trustedURI != nil {
for {
if r.trustedURI.Scheme != redirectURI.Scheme || r.trustedURI.Host != redirectURI.Host {
break
}
if originURI.Scheme != "" && (r.trustedURI.Scheme != originURI.Scheme || r.trustedURI.Host != originURI.Host) {
break
}
trusted = true
break
}
}
// Lookup client registration.
r.mutex.RLock()
registration, _ := r.clients[clientID]
r.mutex.RUnlock()
if registration == nil && strings.HasPrefix(clientID, DynamicStatelessClientIDPrefix) {
trusted = false
dynamic = true
}
// Lookup dynamic clients when it makes sense.
if dynamic && registration == nil {
registration, _ = r.getDynamicClient(clientID)
}
if registration != nil {
redirectURIBase := &url.URL{
Scheme: redirectURI.Scheme,
Host: redirectURI.Host,
Path: redirectURI.Path,
}
err = r.Validate(registration, clientSecret, redirectURIBase.String(), originURIString, withoutSecret)
displayName = registration.Name
trusted = registration.Trusted
} else {
if trusted {
// Always let in implicitly trusted clients.
err = nil
} else {
err = fmt.Errorf("unknown client_id: %v", clientID)
}
}
if err != nil {
return nil, err
}
redirecURIString := redirectURI.String()
r.logger.WithFields(logrus.Fields{
"trusted": trusted,
"client_id": clientID,
"redirect_uri": redirecURIString,
"known": registration != nil,
}).Debugln("identifier client lookup")
return &Details{
ID: clientID,
RedirectURI: redirecURIString,
DisplayName: displayName,
Trusted: trusted,
Registration: registration,
}, nil
}
// Get returns the registered clients registration for the provided client ID.
func (r *Registry) Get(ctx context.Context, clientID string) (*ClientRegistration, bool) {
// Lookup client registration.
r.mutex.RLock()
registration, ok := r.clients[clientID]
r.mutex.RUnlock()
if ok {
return registration, true
}
return r.getDynamicClient(clientID)
}
func (r *Registry) getDynamicClient(clientID string) (*ClientRegistration, bool) {
var registration *ClientRegistration
if len(clientID) >= len(DynamicStatelessClientIDPrefix) {
tokenString := clientID[len(DynamicStatelessClientIDPrefix):]
if token, err := jwt.ParseWithClaims(tokenString, &RegistrationClaims{}, func(token *jwt.Token) (interface{}, error) {
if r.StatelessValidator == nil {
return nil, fmt.Errorf("no validator for dynamic client ids")
}
return r.StatelessValidator(token)
}); err == nil {
if claims, ok := token.Claims.(*RegistrationClaims); ok && token.Valid {
// TODO(longsleep): Add secure client secret.
registration = claims.ClientRegistration
registration.ID = clientID
registration.Secret = claims.RegisteredClaims.Subject
registration.Dynamic = true
}
}
}
return registration, registration != nil
}
+34
View File
@@ -0,0 +1,34 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package identity
import (
"net/url"
"github.com/sirupsen/logrus"
)
// Config defines a IdentityManager's configuration settings.
type Config struct {
SignInFormURI *url.URL
SignedOutURI *url.URL
ScopesSupported []string
Logger logrus.FieldLogger
}
+44
View File
@@ -0,0 +1,44 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package identity
import (
"context"
)
// key is an unexported type for keys defined in this package.
// This prevents collisions with keys defined in other packages.
type key int
// authRecordKey is the key for identity.AuthRecord in Contexts. It is
// unexported; clients use identity.NewContext and identity.FromContext
// instead of using this key directly.
const (
authRecordKey key = iota
)
// NewContext returns a new Context that carries value auth.
func NewContext(ctx context.Context, auth AuthRecord) context.Context {
return context.WithValue(ctx, authRecordKey, auth)
}
// FromContext returns the AuthRecord value stored in ctx, if any.
func FromContext(ctx context.Context) (AuthRecord, bool) {
auth, ok := ctx.Value(authRecordKey).(AuthRecord)
return auth, ok
}
+83
View File
@@ -0,0 +1,83 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package identity
import (
"net/url"
)
// IsHandledError is an error which tells that the backend has handled
// the request and all further handling should stop
type IsHandledError struct {
}
// Error implements the error interface.
func (err *IsHandledError) Error() string {
return "is_handled"
}
// RedirectError is an error which backends can return if a
// redirection is required.
type RedirectError struct {
id string
redirectURI *url.URL
}
// NewRedirectError creates a new corresponding error with the
// provided id and redirect URL.
func NewRedirectError(id string, redirectURI *url.URL) *RedirectError {
return &RedirectError{
id: id,
redirectURI: redirectURI,
}
}
// Error implements the error interface.
func (err *RedirectError) Error() string {
return err.id
}
// RedirectURI returns the redirection URL of the accociated error.
func (err *RedirectError) RedirectURI() *url.URL {
return err.redirectURI
}
// LoginRequiredError which backends can return to indicate that sign-in is
// required.
type LoginRequiredError struct {
id string
signInURI *url.URL
}
// NewLoginRequiredError creates a new corresponding error with the provided id.
func NewLoginRequiredError(id string, signInURI *url.URL) *LoginRequiredError {
return &LoginRequiredError{
id: id,
signInURI: signInURI,
}
}
// Error implements the error interface.
func (err *LoginRequiredError) Error() string {
return err.id
}
// SignInURI returns the sign-in URL of the accociated error.
func (err *LoginRequiredError) SignInURI() *url.URL {
return err.signInURI
}
+48
View File
@@ -0,0 +1,48 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package identity
import (
"context"
"net/http"
"github.com/gorilla/mux"
"github.com/libregraph/lico/oidc/payload"
)
// Manager is a interface to define a identity manager.
type Manager interface {
Authenticate(ctx context.Context, rw http.ResponseWriter, req *http.Request, ar *payload.AuthenticationRequest, next Manager) (AuthRecord, error)
Authorize(ctx context.Context, rw http.ResponseWriter, req *http.Request, ar *payload.AuthenticationRequest, auth AuthRecord) (AuthRecord, error)
EndSession(ctx context.Context, rw http.ResponseWriter, req *http.Request, esr *payload.EndSessionRequest) error
ApproveScopes(ctx context.Context, sub string, audience string, approvedScopesList map[string]bool) (string, error)
ApprovedScopes(ctx context.Context, sub string, audience string, ref string) (map[string]bool, error)
Fetch(ctx context.Context, userID string, sessionRef *string, scopes map[string]bool, requestedClaimsMaps []*payload.ClaimsRequestMap, requestedScopes map[string]bool) (AuthRecord, bool, error)
Name() string
ScopesSupported(scopes map[string]bool) []string
ClaimsSupported(claims []string) []string
AddRoutes(ctx context.Context, router *mux.Router)
OnSetLogon(func(ctx context.Context, rw http.ResponseWriter, user User) error) error
OnUnsetLogon(func(ctx context.Context, rw http.ResponseWriter) error) error
}
+475
View File
@@ -0,0 +1,475 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package managers
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/gorilla/mux"
"github.com/libregraph/oidc-go"
"github.com/longsleep/rndm"
"github.com/sirupsen/logrus"
konnect "github.com/libregraph/lico"
"github.com/libregraph/lico/identity"
"github.com/libregraph/lico/managers"
"github.com/libregraph/lico/oidc/payload"
"github.com/libregraph/lico/version"
)
const cookieIdentityManagerName = "cookie"
// CookieIdentityManager implements an identity manager which passes through
// received HTTP cookies to a HTTP backend..
type CookieIdentityManager struct {
backendURI *url.URL
allowedCookies map[string]bool
scopesSupported []string
signInFormURI string
logger logrus.FieldLogger
client *http.Client
encryptionManager *EncryptionManager
}
// NewCookieIdentityManager creates a new CookieIdentityManager from the
// provided parameters.
func NewCookieIdentityManager(c *identity.Config, backendURI *url.URL, cookieNames []string, timeout time.Duration, transport http.RoundTripper) *CookieIdentityManager {
if transport == nil {
transport = http.DefaultTransport
}
client := &http.Client{
Timeout: timeout,
Transport: transport,
}
var allowedCookies map[string]bool
if len(cookieNames) != 0 {
allowedCookies = make(map[string]bool)
for _, n := range cookieNames {
allowedCookies[n] = true
}
}
im := &CookieIdentityManager{
backendURI: backendURI,
allowedCookies: allowedCookies,
signInFormURI: c.SignInFormURI.String(),
logger: c.Logger,
client: client,
scopesSupported: setupSupportedScopes([]string{
oidc.ScopeProfile,
oidc.ScopeEmail,
konnect.ScopeNumericID,
}, nil, c.ScopesSupported),
}
return im
}
// RegisterManagers registers the provided managers,
func (im *CookieIdentityManager) RegisterManagers(mgrs *managers.Managers) error {
im.encryptionManager = mgrs.Must("encryption").(*EncryptionManager)
return nil
}
type cookieUser struct {
raw string
name string
email string
id int64
claims jwt.MapClaims
}
func (u *cookieUser) Raw() string {
return u.raw
}
func (u *cookieUser) Subject() string {
sub, _ := getPublicSubject([]byte(u.raw), []byte(cookieIdentityManagerName))
return sub
}
func (u *cookieUser) Name() string {
return u.name
}
func (u *cookieUser) Email() string {
return u.email
}
func (u *cookieUser) EmailVerified() bool {
return false
}
func (u *cookieUser) ID() int64 {
return u.id
}
func (u *cookieUser) Claims() jwt.MapClaims {
return u.claims
}
type cookieBackendResponse struct {
Subject string `json:"sub"`
Name string `json:"name"`
Email string `json:"email"`
ID int64 `json:"id"`
}
func (im *CookieIdentityManager) backendRequest(ctx context.Context, encodedCookies string, headers http.Header) (*cookieUser, error) {
if encodedCookies == "" {
// Fastpath, do nothing when no cookies.
return nil, nil
}
request, err := http.NewRequest(http.MethodPost, im.backendURI.String(), nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %v", err)
}
// Copy over some request headers which are used for fingerprinting sessions.
request.Header.Set("Accept-Language", headers.Get("Accept-Language"))
request.Header.Set("User-Agent", headers.Get("User-Agent"))
request.Header.Set("Connection", "keep-alive") // XXX(longsleep): This is part of the Kopano Webapp finger print and we do not really know what the browser sent on sign-in :/
request.Header.Set("X-Konnect-Request", fmt.Sprintf("1/%s", version.Version))
request.Header.Set("Cookie", encodedCookies)
request = request.WithContext(ctx)
response, err := im.client.Do(request)
if err != nil {
return nil, fmt.Errorf("request failed: %v", err)
}
body, err := ioutil.ReadAll(response.Body)
response.Body.Close()
if err != nil {
return nil, fmt.Errorf("read response failed: %v", err)
}
switch response.StatusCode {
case http.StatusOK:
fallthrough
case http.StatusAccepted:
// breaks
case http.StatusUnauthorized:
fallthrough
case http.StatusForbidden:
// Not signed in.
return nil, nil
default:
return nil, fmt.Errorf("request returned error code: %v", response.StatusCode)
}
payload := &cookieBackendResponse{}
err = json.Unmarshal(body, payload)
if err != nil {
return nil, fmt.Errorf("failed to parse response: %v", err)
}
encryptedCookies, err := im.encryptionManager.EncryptStringToHexString(encodedCookies)
if err != nil {
return nil, fmt.Errorf("failed to encrypt cookies: %v", err)
}
claims := make(jwt.MapClaims)
claims["cookie.v"] = encryptedCookies
claims["cookie.al"] = headers.Get("Accept-Language")
claims["cookie.ua"] = headers.Get("User-Agent")
claims[konnect.IdentifiedUserIDClaim] = payload.Subject
user := &cookieUser{
raw: payload.Subject,
email: payload.Email,
name: payload.Name,
id: payload.ID,
claims: claims,
}
return user, nil
}
// Authenticate implements the identity.Manager interface.
func (im *CookieIdentityManager) Authenticate(ctx context.Context, rw http.ResponseWriter, req *http.Request, ar *payload.AuthenticationRequest, next identity.Manager) (identity.AuthRecord, error) {
// Process incoming cookies, filter, and encode to string.
var encodedCookies []string
for _, cookie := range req.Cookies() {
if im.allowedCookies != nil {
if allowed, _ := im.allowedCookies[cookie.Name]; !allowed {
continue
}
}
encodedCookies = append(encodedCookies, cookie.String())
}
encodedCookiesString := strings.Join(encodedCookies, "; ")
user, err := im.backendRequest(ctx, encodedCookiesString, req.Header)
if err != nil {
// Error, directly return.
im.logger.Errorln("CookieIdentityManager: backend request error", err)
return nil, ar.NewError(oidc.ErrorCodeOAuth2ServerError, "CookieIdentityManager: backend request error")
}
if user == nil {
// Not signed in.
err = ar.NewError(oidc.ErrorCodeOIDCLoginRequired, "CookieIdentityManager: not signed in")
}
// Check prompt value.
switch {
case ar.Prompts[oidc.PromptNone] == true:
if err != nil {
// Never show sign-in, directly return error.
return nil, err
}
case ar.Prompts[oidc.PromptLogin] == true:
if err == nil {
// Enforce to show sign-in, when signed in.
err = ar.NewError(oidc.ErrorCodeOIDCLoginRequired, "CookieIdentityManager: prompt=login request")
}
case ar.Prompts[oidc.PromptSelectAccount] == true:
// Not supported, just ignore.
fallthrough
default:
// Let all other prompt values pass.
}
// More checks.
if err == nil {
var sub string
if user != nil {
sub = user.Subject()
}
err = ar.Verify(sub)
if err != nil {
return nil, err
}
}
if err != nil {
u, _ := url.Parse(im.signInFormURI)
return nil, identity.NewLoginRequiredError(err.Error(), u)
}
auth := identity.NewAuthRecord(im, user.Subject(), nil, nil, nil)
auth.SetUser(user)
return auth, nil
}
// Authorize implements the identity.Manager interface.
func (im *CookieIdentityManager) Authorize(ctx context.Context, rw http.ResponseWriter, req *http.Request, ar *payload.AuthenticationRequest, auth identity.AuthRecord) (identity.AuthRecord, error) {
promptConsent := false
var approvedScopes map[string]bool
// Check prompt value.
switch {
case ar.Prompts[oidc.PromptConsent] == true:
promptConsent = true
default:
// Let all other prompt values pass.
}
// Fastpath for known clients.
switch ar.ClientID {
default:
// TODO(longsleep): Implement previous consent checks via backend.
approvedScopes = ar.Scopes
}
// Offline access validation.
// http://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
if ok, _ := ar.Scopes[oidc.ScopeOfflineAccess]; ok {
if !promptConsent {
// Ensure that the prompt parameter contains consent unless
// other conditions for processing the request permitting offline
// access to the requested resources are in place; unless one or
// both of these conditions are fulfilled, then it MUST ignore the
// offline_access request,
delete(ar.Scopes, oidc.ScopeOfflineAccess)
im.logger.Debugln("consent is required for offline access but not given, removed offline_access scope")
} else {
// NOTE(longsleep): Cookie identity relies on the presence of session cookies know to a backend. Thus offline access is not supported.
im.logger.Warnf("CookieIdentityManager: offline_access requested but not supported, removed offline_access scope")
delete(ar.Scopes, oidc.ScopeOfflineAccess)
}
}
if promptConsent {
if ar.Prompts[oidc.PromptNone] == true {
return auth, ar.NewError(oidc.ErrorCodeOIDCInteractionRequired, "consent required")
}
// TODO(longsleep): Implement permissions page / consent prompt.
return auth, ar.NewError(oidc.ErrorCodeOIDCInteractionRequired, "consent required")
}
auth.AuthorizeScopes(approvedScopes)
auth.AuthorizeClaims(ar.Claims)
return auth, nil
}
// EndSession implements the identity.Manager interface.
func (im *CookieIdentityManager) EndSession(ctx context.Context, rw http.ResponseWriter, req *http.Request, ar *payload.EndSessionRequest) error {
// XXX
return nil
}
// ApproveScopes implements the Backend interface.
func (im *CookieIdentityManager) ApproveScopes(ctx context.Context, sub string, audience string, approvedScopes map[string]bool) (string, error) {
ref := rndm.GenerateRandomString(32)
// TODO(longsleep): Store generated ref with provided data.
return ref, nil
}
// ApprovedScopes implements the Backend interface.
func (im *CookieIdentityManager) ApprovedScopes(ctx context.Context, sub string, audience string, ref string) (map[string]bool, error) {
if ref == "" {
return nil, fmt.Errorf("SimplePasswdBackend: invalid ref")
}
return nil, nil
}
// Fetch implements the identity.Manager interface.
func (im *CookieIdentityManager) Fetch(ctx context.Context, userID string, sessionRef *string, scopes map[string]bool, requestedClaimsMaps []*payload.ClaimsRequestMap, requestedScopes map[string]bool) (identity.AuthRecord, bool, error) {
var user identity.PublicUser
// Try identty from context.
auth, _ := identity.FromContext(ctx)
if auth != nil {
if auth.User().Raw() != userID {
return nil, false, fmt.Errorf("CookieIdentityManager: wrong user - this should not happen")
}
user = auth.User() // This gets the user when added during Authenticate.
}
if user == nil {
// Try claims from context.
claims, _ := konnect.FromClaimsContext(ctx)
if claims != nil {
var identityClaimsMap jwt.MapClaims
switch c := claims.(type) {
case *konnect.AccessTokenClaims:
identityClaimsMap = c.IdentityClaims
case *konnect.RefreshTokenClaims:
identityClaimsMap = c.IdentityClaims
case jwt.MapClaims:
identityClaimsMap = c
default:
return nil, false, fmt.Errorf("CookieIdentityManager: unknown identity claims type")
}
var err error
var encodedCookies string
encryptedCookies, _ := identityClaimsMap["cookie.v"].(string)
if encryptedCookies != "" {
encodedCookies, err = im.encryptionManager.DecryptHexToString(encryptedCookies)
if err != nil {
return nil, false, fmt.Errorf("CookieIdentityManager: %v", err)
}
} else {
encodedCookies = encryptedCookies
}
headers := http.Header{}
if al, ok := identityClaimsMap["cookie.al"]; ok {
headers.Set("Accept-Language", al.(string))
}
if ua, ok := identityClaimsMap["cookie.ua"]; ok {
headers.Set("User-Agent", ua.(string))
}
user, err = im.backendRequest(ctx, encodedCookies, headers)
if err != nil {
// Error, directly return.
im.logger.WithError(err).Errorln("CookieIdentityManager: backend request error")
return nil, false, fmt.Errorf("CookieIdentityManager: backend request error")
}
}
}
if user == nil {
return nil, false, fmt.Errorf("CookieIdentityManager: no user")
}
if user.Raw() != userID {
return nil, false, fmt.Errorf("CookieIdentityManager: wrong user")
}
authorizedScopes, _ := identity.AuthorizeScopes(im, user, scopes)
claims := identity.GetUserClaimsForScopes(user, authorizedScopes, requestedClaimsMaps)
auth = identity.NewAuthRecord(im, user.Subject(), authorizedScopes, nil, claims)
auth.SetUser(user)
return auth, true, nil
}
// Name implements the identity.Manager interface.
func (im *CookieIdentityManager) Name() string {
return cookieIdentityManagerName
}
// ScopesSupported implements the identity.Manager interface.
func (im *CookieIdentityManager) ScopesSupported(scopes map[string]bool) []string {
return im.scopesSupported
}
// ClaimsSupported implements the identity.Manager interface.
func (im *CookieIdentityManager) ClaimsSupported(claims []string) []string {
return []string{
oidc.NameClaim,
oidc.EmailClaim,
oidc.EmailVerifiedClaim,
}
}
// AddRoutes implements the identity.Manager interface.
func (im *CookieIdentityManager) AddRoutes(ctx context.Context, router *mux.Router) {
}
// OnSetLogon implements the identity.Manager interface.
func (im *CookieIdentityManager) OnSetLogon(func(ctx context.Context, rw http.ResponseWriter, user identity.User) error) error {
return nil
}
// OnUnsetLogon implements the identity.Manager interface.
func (im *CookieIdentityManager) OnUnsetLogon(func(ctx context.Context, rw http.ResponseWriter) error) error {
return nil
}
+228
View File
@@ -0,0 +1,228 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package managers
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
"github.com/gorilla/mux"
"github.com/libregraph/oidc-go"
"github.com/longsleep/rndm"
konnect "github.com/libregraph/lico"
"github.com/libregraph/lico/identity"
"github.com/libregraph/lico/oidc/payload"
)
const dummyIdentityManagerName = "dummy"
// DummyIdentityManager implements an identity manager which always grants
// access to a fixed user id.
type DummyIdentityManager struct {
sub string
scopesSupported []string
}
// NewDummyIdentityManager creates a new DummyIdentityManager from the
// provided parameters.
func NewDummyIdentityManager(c *identity.Config, sub string) *DummyIdentityManager {
im := &DummyIdentityManager{
sub: sub,
scopesSupported: setupSupportedScopes([]string{
oidc.ScopeProfile,
oidc.ScopeEmail,
}, nil, c.ScopesSupported),
}
return im
}
type dummyUser struct {
raw string
}
func (u *dummyUser) Raw() string {
return u.raw
}
func (u *dummyUser) Subject() string {
sub, _ := getPublicSubject([]byte(u.raw), []byte(dummyIdentityManagerName))
return sub
}
func (u *dummyUser) Email() string {
return fmt.Sprintf("%s@%s.local", u.raw, u.raw)
}
func (u *dummyUser) EmailVerified() bool {
return false
}
func (u *dummyUser) Name() string {
return fmt.Sprintf("Foo %s", strings.Title(u.raw))
}
func (u *dummyUser) Claims() jwt.MapClaims {
claims := make(jwt.MapClaims)
claims[konnect.IdentifiedUserIDClaim] = u.raw
return claims
}
// Authenticate implements the identity.Manager interface.
func (im *DummyIdentityManager) Authenticate(ctx context.Context, rw http.ResponseWriter, req *http.Request, ar *payload.AuthenticationRequest, next identity.Manager) (identity.AuthRecord, error) {
user := &dummyUser{im.sub}
// Check request.
err := ar.Verify(user.Subject())
if err != nil {
return nil, err
}
auth := identity.NewAuthRecord(im, user.Subject(), nil, nil, nil)
auth.SetUser(user)
return auth, nil
}
// Authorize implements the identity.Manager interface.
func (im *DummyIdentityManager) Authorize(ctx context.Context, rw http.ResponseWriter, req *http.Request, ar *payload.AuthenticationRequest, auth identity.AuthRecord) (identity.AuthRecord, error) {
promptConsent := false
var approvedScopes map[string]bool
// Check prompt value.
switch {
case ar.Prompts[oidc.PromptConsent] == true:
promptConsent = true
default:
// Let all other prompt values pass.
}
// TODO(longsleep): Move the code below to general function.
// TODO(longsleep): Validate scopes and force prompt.
approvedScopes = ar.Scopes
// Offline access validation.
// http://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
if ok, _ := ar.Scopes[oidc.ScopeOfflineAccess]; ok {
if !promptConsent {
// Ensure that the prompt parameter contains consent unless
// other conditions for processing the request permitting offline
// access to the requested resources are in place; unless one or
// both of these conditions are fulfilled, then it MUST ignore the
// offline_access request,
delete(ar.Scopes, oidc.ScopeOfflineAccess)
}
}
if promptConsent {
if ar.Prompts[oidc.PromptNone] == true {
return auth, ar.NewError(oidc.ErrorCodeOIDCInteractionRequired, "consent required")
}
// TODO(longsleep): Implement consent page.
return auth, ar.NewError(oidc.ErrorCodeOIDCInteractionRequired, "consent required, but page not implemented")
}
auth.AuthorizeScopes(approvedScopes)
auth.AuthorizeClaims(ar.Claims)
return auth, nil
}
// EndSession implements the identity.Manager interface.
func (im *DummyIdentityManager) EndSession(ctx context.Context, rw http.ResponseWriter, req *http.Request, esr *payload.EndSessionRequest) error {
user := &dummyUser{im.sub}
err := esr.Verify(user.Subject())
if err != nil {
return err
}
return nil
}
// ApproveScopes implements the Backend interface.
func (im *DummyIdentityManager) ApproveScopes(ctx context.Context, sub string, audience string, approvedScopes map[string]bool) (string, error) {
ref := rndm.GenerateRandomString(32)
// TODO(longsleep): Store generated ref with provided data.
return ref, nil
}
// ApprovedScopes implements the Backend interface.
func (im *DummyIdentityManager) ApprovedScopes(ctx context.Context, sub string, audience string, ref string) (map[string]bool, error) {
if ref == "" {
return nil, fmt.Errorf("SimplePasswdBackend: invalid ref")
}
return nil, nil
}
// Fetch implements the identity.Manager interface.
func (im *DummyIdentityManager) Fetch(ctx context.Context, userID string, sessionRef *string, scopes map[string]bool, requestedClaimsMaps []*payload.ClaimsRequestMap, requestedScopes map[string]bool) (identity.AuthRecord, bool, error) {
if userID != im.sub {
return nil, false, fmt.Errorf("DummyIdentityManager: no user")
}
user := &dummyUser{im.sub}
authorizedScopes, _ := identity.AuthorizeScopes(im, user, scopes)
claims := identity.GetUserClaimsForScopes(user, authorizedScopes, requestedClaimsMaps)
return identity.NewAuthRecord(im, user.Subject(), authorizedScopes, nil, claims), true, nil
}
// Name implements the identity.Manager interface.
func (im *DummyIdentityManager) Name() string {
return dummyIdentityManagerName
}
// ScopesSupported implements the identity.Manager interface.
func (im *DummyIdentityManager) ScopesSupported(scopes map[string]bool) []string {
return im.scopesSupported
}
// ClaimsSupported implements the identity.Manager interface.
func (im *DummyIdentityManager) ClaimsSupported(claims []string) []string {
return []string{
oidc.NameClaim,
oidc.EmailClaim,
oidc.EmailVerifiedClaim,
}
}
// AddRoutes implements the identity.Manager interface.
func (im *DummyIdentityManager) AddRoutes(ctx context.Context, router *mux.Router) {
}
// OnSetLogon implements the identity.Manager interface.
func (im *DummyIdentityManager) OnSetLogon(func(ctx context.Context, rw http.ResponseWriter, user identity.User) error) error {
return nil
}
// OnUnsetLogon implements the identity.Manager interface.
func (im *DummyIdentityManager) OnUnsetLogon(func(ctx context.Context, rw http.ResponseWriter) error) error {
return nil
}
@@ -0,0 +1,113 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package managers
import (
"encoding/hex"
"fmt"
"github.com/libregraph/lico/encryption"
)
// EncryptionManager implements string encryption functions with a key.
type EncryptionManager struct {
key *[encryption.KeySize]byte
}
// NewEncryptionManager creates a new EncryptionManager with the provided key.
func NewEncryptionManager(key *[encryption.KeySize]byte) (*EncryptionManager, error) {
em := &EncryptionManager{
key: key,
}
return em, nil
}
// SetKey sets the provided key for the accociated manager.
func (em *EncryptionManager) SetKey(key []byte) error {
switch len(key) {
case encryption.KeySize:
// all good, breaks
case hex.EncodedLen(encryption.KeySize):
// try to decode with hex
dst := make([]byte, encryption.KeySize)
if _, err := hex.Decode(dst, key); err == nil {
key = dst
}
}
if len(key) != encryption.KeySize {
return fmt.Errorf("encryption key size error, is %d, want %d", len(key), encryption.KeySize)
}
em.key = new([encryption.KeySize]byte)
copy(em.key[:], key[:encryption.KeySize])
return nil
}
// GetKeySize returns the size of the accociated manager's key.
func (em *EncryptionManager) GetKeySize() int {
return len(em.key)
}
// EncryptStringToHexString encrypts a plaintext string with the accociated
// key and returns the hex encoded ciphertext as string.
func (em *EncryptionManager) EncryptStringToHexString(plaintext string) (string, error) {
ciphertext, err := em.Encrypt([]byte(plaintext))
if err != nil {
return "", err
}
return hex.EncodeToString(ciphertext), nil
}
// Encrypt encrypts plaintext []byte with the accociated key and returns
// ciphertext []byte.
func (em *EncryptionManager) Encrypt(plaintext []byte) ([]byte, error) {
ciphertext, err := encryption.Encrypt(plaintext, em.key)
if err != nil {
return nil, err
}
return ciphertext, nil
}
// DecryptHexToString decrypts a hex encoded string with the accociated key
// and returns the plain text as string.
func (em *EncryptionManager) DecryptHexToString(ciphertextHex string) (string, error) {
ciphertext, err := hex.DecodeString(ciphertextHex)
if err != nil {
return "", err
}
plaintext, err := em.Decrypt(ciphertext)
if err != nil {
return "", err
}
return string(plaintext), nil
}
// Decrypt decrypts ciphertext []byte with the accociated key and returns
// plaintext []byte.
func (em *EncryptionManager) Decrypt(ciphertext []byte) ([]byte, error) {
plaintext, err := encryption.Decrypt(ciphertext, em.key)
if err != nil {
return nil, err
}
return plaintext, nil
}
+503
View File
@@ -0,0 +1,503 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package managers
import (
"context"
"fmt"
"net/http"
"github.com/golang-jwt/jwt/v5"
"github.com/gorilla/mux"
"github.com/libregraph/oidc-go"
"github.com/longsleep/rndm"
"github.com/sirupsen/logrus"
konnect "github.com/libregraph/lico"
"github.com/libregraph/lico/identity"
"github.com/libregraph/lico/identity/clients"
"github.com/libregraph/lico/managers"
"github.com/libregraph/lico/oidc/payload"
"github.com/libregraph/lico/utils"
)
const guestIdentitityManagerName = "guest"
// GuestIdentityManager implements an identity manager for guest users.
type GuestIdentityManager struct {
scopesSupported []string
claimsSupported []string
logger logrus.FieldLogger
clients *clients.Registry
onSetLogonCallbacks []func(ctx context.Context, rw http.ResponseWriter, user identity.User) error
onUnsetLogonCallbacks []func(ctx context.Context, rw http.ResponseWriter) error
}
// NewGuestIdentityManager creates a new GuestIdentityManager from the
// provided parameters.
func NewGuestIdentityManager(c *identity.Config) *GuestIdentityManager {
im := &GuestIdentityManager{
scopesSupported: setupSupportedScopes([]string{}, []string{
konnect.ScopeNumericID,
oidc.ScopeProfile,
oidc.ScopeEmail,
}, c.ScopesSupported),
claimsSupported: []string{
oidc.NameClaim,
oidc.FamilyNameClaim,
oidc.GivenNameClaim,
oidc.EmailClaim,
oidc.EmailVerifiedClaim,
},
logger: c.Logger,
onSetLogonCallbacks: make([]func(ctx context.Context, rw http.ResponseWriter, user identity.User) error, 0),
onUnsetLogonCallbacks: make([]func(ctx context.Context, rw http.ResponseWriter) error, 0),
}
return im
}
type guestUser struct {
raw string
email string
emailVerified bool
name string
familyName string
givenName string
}
func newGuestUserFromClaims(claims jwt.MapClaims) *guestUser {
isGuestClaim, ok := claims[konnect.IdentifiedUserIsGuest]
if !ok {
return nil
}
isGuest, _ := isGuestClaim.(bool)
if !isGuest {
return nil
}
idClaim, ok := claims[konnect.IdentifiedUserIDClaim]
if !ok {
return nil
}
dataClaim, ok := claims[konnect.IdentifiedData]
if !ok {
return nil
}
user := &guestUser{
raw: idClaim.(string),
}
data, _ := dataClaim.(map[string]interface{})
for name, value := range data {
switch name {
case "e":
user.email, _ = value.(string)
case "ev":
if v, _ := value.(int); v == 1 {
user.emailVerified = true
}
case "n":
user.name, _ = value.(string)
case "nf":
user.familyName, _ = value.(string)
case "ng":
user.givenName, _ = value.(string)
}
}
return user
}
type minimalGuestUserData struct {
E string `json:"e,omitempty"`
EV int `json:"ev,omitempty"`
N string `json:"n,omitempty"`
NF string `json:"nf,omitempty"`
NG string `json:"ng,omitempty"`
}
func (u *guestUser) Raw() string {
return u.raw
}
func (u *guestUser) Subject() string {
sub, _ := getPublicSubject([]byte(u.raw), []byte(guestIdentitityManagerName))
return sub
}
func (u *guestUser) Email() string {
return u.email
}
func (u *guestUser) EmailVerified() bool {
return u.emailVerified
}
func (u *guestUser) Name() string {
return u.name
}
func (u *guestUser) FamilyName() string {
return u.familyName
}
func (u *guestUser) GivenName() string {
return u.givenName
}
func (u *guestUser) Claims() jwt.MapClaims {
claims := make(jwt.MapClaims)
claims[konnect.IdentifiedUserIDClaim] = u.raw
claims[konnect.IdentifiedUserIsGuest] = true
m := &minimalGuestUserData{
E: u.email,
N: u.name,
NF: u.familyName,
NG: u.givenName,
}
if u.emailVerified {
m.EV = 1
}
claims[konnect.IdentifiedData] = m
return claims
}
// RegisterManagers registers the provided managers,
func (im *GuestIdentityManager) RegisterManagers(mgrs *managers.Managers) error {
im.clients = mgrs.Must("clients").(*clients.Registry)
return nil
}
// Authenticate implements the identity.Manager interface.
func (im *GuestIdentityManager) Authenticate(ctx context.Context, rw http.ResponseWriter, req *http.Request, ar *payload.AuthenticationRequest, next identity.Manager) (identity.AuthRecord, error) {
// Check if required scopes are there.
if !ar.Scopes[konnect.ScopeGuestOK] {
return nil, ar.NewError(oidc.ErrorCodeOIDCLoginRequired, "GuestIdentityManager: required scope missing")
}
// Authenticate with signed client request object, so that must be there.
if ar.Request == nil {
return nil, ar.NewError(oidc.ErrorCodeOIDCInvalidRequestObject, "GuestIdentityManager: no request object")
}
// Further checks of signed claims.
roc, ok := ar.Request.Claims.(*payload.RequestObjectClaims)
if !ok {
return nil, ar.NewBadRequest(oidc.ErrorCodeOAuth2InvalidRequest, "GuestIdentityManager: invalid claims request")
}
// NOTE(longsleep): Require claims in request object to ensure that the
// claims requested come from there.
if roc.Claims == nil || ar.Claims == nil {
return nil, ar.NewError(oidc.ErrorCodeOAuth2InvalidRequest, "GuestIdentityManager: missing claims request")
}
// NOTE(longsleep): Guest mode requires ID token claims request with the
// guest claim set to an expected value.
if ar.Claims.IDToken == nil {
return nil, ar.NewError(oidc.ErrorCodeOAuth2InvalidRequest, "GuestIdentityManager: missing claims request for id_token")
}
guest, ok := ar.Claims.IDToken.GetStringValue("guest")
if !ok {
return nil, ar.NewError(oidc.ErrorCodeOAuth2InvalidRequest, "GuestIdentityManager: missing claim guest in id_token claims request")
}
// Ensure that request object claim is signed.
if ar.Request.Method == jwt.SigningMethodNone {
return nil, ar.NewBadRequest(oidc.ErrorCodeOIDCInvalidRequestObject, "GuestIdentityManager: request object must be signed")
}
if guest == "" {
return nil, ar.NewBadRequest(oidc.ErrorCodeOAuth2InvalidRequest, "GuestIdentityManager: invalid claim guest in id_token claims request")
}
// Additional email and profile claim values will be taken over into the
// guest user data.
email, _ := ar.Claims.IDToken.GetStringValue(oidc.EmailClaim)
var emailVerified bool
if emailVerifiedRaw, ok := ar.Claims.IDToken.Get(oidc.EmailVerifiedClaim); ok {
emailVerified, _ = emailVerifiedRaw.Value.(bool)
}
name, _ := ar.Claims.IDToken.GetStringValue(oidc.NameClaim)
familyName, _ := ar.Claims.IDToken.GetStringValue(oidc.FamilyNameClaim)
givenName, _ := ar.Claims.IDToken.GetStringValue(oidc.GivenNameClaim)
// Make new user with the provided signed information.
sub := guest
user := &guestUser{
raw: sub,
email: email,
emailVerified: emailVerified,
name: name,
familyName: familyName,
givenName: givenName,
}
// TODO(longsleep): Add additional claims to user from the claims request
// after filtering.
// Check request.
err := ar.Verify(user.Subject())
if err != nil {
return nil, err
}
auth := identity.NewAuthRecord(im, user.Subject(), nil, nil, nil)
auth.SetUser(user)
return auth, nil
}
// Authorize implements the identity.Manager interface.
func (im *GuestIdentityManager) Authorize(ctx context.Context, rw http.ResponseWriter, req *http.Request, ar *payload.AuthenticationRequest, auth identity.AuthRecord) (identity.AuthRecord, error) {
promptConsent := false
var approvedScopes map[string]bool
// Check prompt value.
switch {
case ar.Prompts[oidc.PromptConsent] == true:
promptConsent = true
default:
// Let all other prompt values pass.
}
// Offline access validation.
// http://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
if ok, _ := ar.Scopes[oidc.ScopeOfflineAccess]; ok {
if !promptConsent {
// Ensure that the prompt parameter contains consent unless
// other conditions for processing the request permitting offline
// access to the requested resources are in place; unless one or
// both of these conditions are fulfilled, then it MUST ignore the
// offline_access request,
delete(ar.Scopes, oidc.ScopeOfflineAccess)
}
}
// Authenticate with signed client request object, so that must be there.
if ar.Request == nil {
return nil, ar.NewError(oidc.ErrorCodeOIDCInvalidRequestObject, "GuestIdentityManager: authorize without request object")
}
// Further checks of signed claims.
roc, ok := ar.Request.Claims.(*payload.RequestObjectClaims)
if !ok {
return nil, ar.NewBadRequest(oidc.ErrorCodeOAuth2InvalidRequest, "GuestIdentityManager: authorize with invalid claims request")
}
securedDetails := roc.Secure()
if securedDetails == nil {
return nil, ar.NewBadRequest(oidc.ErrorCodeOIDCInvalidRequestObject, "GuestIdentityManager: authorize without secure client")
}
// TODO(longsleep): Validate scopes and force prompt.
if promptConsent {
if ar.Prompts[oidc.PromptNone] == true {
return auth, ar.NewError(oidc.ErrorCodeOIDCInteractionRequired, "consent required")
}
// TODO(longsleep): Implement consent page.
return auth, ar.NewError(oidc.ErrorCodeOIDCInteractionRequired, "consent required, but not supported for guests")
}
origin := ""
if false {
// TODO(longsleep): find a condition when this can be enabled.
origin = utils.OriginFromRequestHeaders(req.Header)
}
clientDetails, err := im.clients.Lookup(req.Context(), ar.ClientID, "", ar.RedirectURI, origin, true)
if err != nil {
return nil, ar.NewError(oidc.ErrorCodeOAuth2AccessDenied, err.Error())
}
if clientDetails.ID != securedDetails.ID {
return nil, ar.NewError(oidc.ErrorCodeOAuth2AccessDenied, "client mismatch")
}
// If not trusted we need to check request scopes.
if clientDetails.Trusted && securedDetails.TrustedScopes == nil {
// NOTE(longsleep): Guest scope validation takes all client provided
// scopes when the trusted client configuration has no trusted scopes
// configured. This can be used for fine grained access control using
// the trusted client configuration.
approvedScopes = ar.Scopes
} else {
supportedScopes := make(map[string]bool)
for _, scope := range im.ScopesSupported(nil) {
supportedScopes[scope] = true
}
// Auto approve all supported scopes.
approvedScopes = make(map[string]bool)
for scope := range ar.Scopes {
if _, ok := supportedScopes[scope]; ok {
approvedScopes[scope] = true
}
}
// Approve all additional scopes which are allowed by the trusted
// client.
for _, scope := range securedDetails.TrustedScopes {
if _, ok := ar.Scopes[scope]; ok {
approvedScopes[scope] = true
}
}
// Always approve openid scope.
if _, ok := ar.Scopes[oidc.ScopeOpenID]; ok {
approvedScopes[oidc.ScopeOpenID] = true
}
// Ensure that guest scope was approved.
if ok, _ := approvedScopes[konnect.ScopeGuestOK]; !ok {
return nil, ar.NewBadRequest(oidc.ErrorCodeOAuth2InvalidRequest, "GuestIdentityManager: client does not authorize "+konnect.ScopeGuestOK+" scope")
}
}
auth.AuthorizeScopes(approvedScopes)
auth.AuthorizeClaims(ar.Claims)
return auth, nil
}
// EndSession implements the identity.Manager interface.
func (im *GuestIdentityManager) EndSession(ctx context.Context, rw http.ResponseWriter, req *http.Request, esr *payload.EndSessionRequest) error {
// TODO(longsleep): Implement end session for guests.
// Trigger callbacks.
for _, f := range im.onUnsetLogonCallbacks {
err := f(ctx, rw)
if err != nil {
return err
}
}
return nil
}
// ApproveScopes implements the Backend interface.
func (im *GuestIdentityManager) ApproveScopes(ctx context.Context, sub string, audience string, approvedScopes map[string]bool) (string, error) {
ref := rndm.GenerateRandomString(32)
// TODO(longsleep): Store generated ref with provided data.
return ref, nil
}
// ApprovedScopes implements the Backend interface.
func (im *GuestIdentityManager) ApprovedScopes(ctx context.Context, sub string, audience string, ref string) (map[string]bool, error) {
if ref == "" {
return nil, fmt.Errorf("GuestIdentityManager: invalid ref")
}
return nil, nil
}
// Fetch implements the identity.Manager interface.
func (im *GuestIdentityManager) Fetch(ctx context.Context, userID string, sessionRef *string, scopes map[string]bool, requestedClaimsMaps []*payload.ClaimsRequestMap, requestedScopes map[string]bool) (identity.AuthRecord, bool, error) {
var user identity.PublicUser
for {
// First check if current context has auth.
if auth, ok := identity.FromContext(ctx); ok {
user = auth.User()
break
}
// Second check if current context has claims with guest identity in it.
if claims, ok := konnect.FromClaimsContext(ctx); ok {
var identityClaims jwt.MapClaims
var identityProvider string
switch c := claims.(type) {
case *konnect.AccessTokenClaims:
identityClaims = c.IdentityClaims
identityProvider = c.IdentityProvider
case *konnect.RefreshTokenClaims:
identityClaims = c.IdentityClaims
identityProvider = c.IdentityProvider
}
if identityClaims != nil && identityProvider == im.Name() {
user = newGuestUserFromClaims(identityClaims)
break
}
}
return nil, false, fmt.Errorf("GuestIdentityManager: no user in context")
}
if user.Raw() != userID {
return nil, false, fmt.Errorf("GuestIdentityManager: wrong user")
}
authorizedScopes, _ := identity.AuthorizeScopes(im, user, scopes)
claims := identity.GetUserClaimsForScopes(user, authorizedScopes, requestedClaimsMaps)
auth := identity.NewAuthRecord(im, user.Subject(), authorizedScopes, nil, claims)
auth.SetUser(user)
return auth, true, nil
}
// Name implements the identity.Manager interface.
func (im *GuestIdentityManager) Name() string {
return guestIdentitityManagerName
}
// ScopesSupported implements the identity.Manager interface.
func (im *GuestIdentityManager) ScopesSupported(scopes map[string]bool) []string {
if scopes != nil {
// NOTE(longsleep): Allow scopes as we get them, since we already validated
// them in authorize.
supported := make([]string, 0)
for scope, ok := range scopes {
if ok {
supported = append(supported, scope)
}
}
return supported
}
return im.scopesSupported
}
// ClaimsSupported implements the identity.Manager interface.
func (im *GuestIdentityManager) ClaimsSupported(claims []string) []string {
return im.claimsSupported
}
// AddRoutes implements the identity.Manager interface.
func (im *GuestIdentityManager) AddRoutes(ctx context.Context, router *mux.Router) {
}
// OnSetLogon implements the identity.Manager interface.
func (im *GuestIdentityManager) OnSetLogon(cb func(ctx context.Context, rw http.ResponseWriter, user identity.User) error) error {
im.onSetLogonCallbacks = append(im.onSetLogonCallbacks, cb)
return nil
}
// OnUnsetLogon implements the identity.Manager interface.
func (im *GuestIdentityManager) OnUnsetLogon(cb func(ctx context.Context, rw http.ResponseWriter) error) error {
im.onUnsetLogonCallbacks = append(im.onUnsetLogonCallbacks, cb)
return nil
}
@@ -0,0 +1,562 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package managers
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/gorilla/mux"
"github.com/libregraph/oidc-go"
"github.com/longsleep/rndm"
"github.com/sirupsen/logrus"
"github.com/libregraph/lico/identifier"
"github.com/libregraph/lico/identity"
"github.com/libregraph/lico/identity/clients"
"github.com/libregraph/lico/managers"
konnectoidc "github.com/libregraph/lico/oidc"
"github.com/libregraph/lico/oidc/payload"
"github.com/libregraph/lico/utils"
)
// IdentifierIdentityManager implements an identity manager which relies on
// Konnect its identifier to provide identity.
type IdentifierIdentityManager struct {
signInFormURI string
signedOutURI string
scopesSupported []string
claimsSupported []string
identifier *identifier.Identifier
clients *clients.Registry
logger logrus.FieldLogger
}
type identifierUser struct {
*identifier.IdentifiedUser
}
func (u *identifierUser) Raw() string {
return u.IdentifiedUser.Subject()
}
func (u *identifierUser) Subject() string {
sub, _ := getPublicSubject([]byte(u.Raw()), []byte(u.IdentifiedUser.BackendName()))
return sub
}
func (u *identifierUser) Scopes() []string {
return u.IdentifiedUser.Scopes()
}
func (u *identifierUser) RequiredScopes() map[string]bool {
lockedScopes := u.IdentifiedUser.LockedScopes()
if lockedScopes == nil {
return nil
}
requiredScopes := make(map[string]bool)
for _, scope := range lockedScopes {
if strings.HasPrefix(scope, "!") {
scope = strings.TrimLeft(scope, "!")
requiredScopes[scope] = false
} else {
requiredScopes[scope] = true
}
}
return requiredScopes
}
func asIdentifierUser(user *identifier.IdentifiedUser) *identifierUser {
return &identifierUser{user}
}
// NewIdentifierIdentityManager creates a new IdentifierIdentityManager from the provided
// parameters.
func NewIdentifierIdentityManager(c *identity.Config, i *identifier.Identifier) *IdentifierIdentityManager {
im := &IdentifierIdentityManager{
signInFormURI: c.SignInFormURI.String(),
signedOutURI: c.SignedOutURI.String(),
scopesSupported: setupSupportedScopes([]string{
oidc.ScopeOfflineAccess,
}, nil, c.ScopesSupported),
claimsSupported: []string{
oidc.NameClaim,
oidc.FamilyNameClaim,
oidc.GivenNameClaim,
oidc.EmailClaim,
oidc.EmailVerifiedClaim,
},
identifier: i,
logger: c.Logger,
}
return im
}
// RegisterManagers registers the provided managers,
func (im *IdentifierIdentityManager) RegisterManagers(mgrs *managers.Managers) error {
im.clients = mgrs.Must("clients").(*clients.Registry)
return im.identifier.RegisterManagers(mgrs)
}
// Authenticate implements the identity.Manager interface.
func (im *IdentifierIdentityManager) Authenticate(ctx context.Context, rw http.ResponseWriter, req *http.Request, ar *payload.AuthenticationRequest, next identity.Manager) (identity.AuthRecord, error) {
var user *identifierUser
var err error
if authenticationErrorID := req.Form.Get("error"); authenticationErrorID != "" {
// Incoming with error. Directly abort and return.
return nil, ar.NewError(authenticationErrorID, req.Form.Get("error_description"))
}
u, _ := im.identifier.GetUserFromLogonCookie(ctx, req, ar.MaxAge, true)
if u != nil {
// TODO(longsleep): Add other user meta data.
user = asIdentifierUser(u)
} else {
// Not signed in.
if mode := req.Form.Get("identifier"); mode == identifier.MustBeSignedIn {
// Identifier mode is set to must, this means that this flow must be authenticated here, and everything
// else is an error. This is for example set, when coming back from an external authority.
im.logger.WithField("mode", mode).Debugln("identifier mode is set, but not signed in")
} else if next != nil {
// Give next handler a chance if any.
if auth, authErr := next.Authenticate(ctx, rw, req, ar, nil); authErr == nil {
// Inner handler success.
// TODO(longsleep): Add check and option to avoid that the inner
// handler can ever return users which exist at the outer.
return auth, authErr
} else {
switch authErr.(type) {
case *payload.AuthenticationError:
// ignore, breaks
case *identity.LoginRequiredError:
// ignore, breaks
case *identity.IsHandledError:
// breaks, breaks
default:
im.logger.WithFields(utils.ErrorAsFields(authErr)).Errorln("inner authorize request failed")
}
}
}
err = ar.NewError(oidc.ErrorCodeOIDCLoginRequired, "IdentifierIdentityManager: not signed in")
}
// Check prompt value.
switch {
case ar.Prompts[oidc.PromptNone] == true:
if err != nil {
// Never show sign-in, directly return error.
return nil, err
}
case ar.Prompts[oidc.PromptLogin] == true:
if err == nil {
// Enforce to show sign-in, when signed in.
err = ar.NewError(oidc.ErrorCodeOIDCLoginRequired, "IdentifierIdentityManager: prompt=login request")
}
case ar.Prompts[oidc.PromptSelectAccount] == true:
if err == nil {
// Enforce to show sign-in, when signed in.
err = ar.NewError(oidc.ErrorCodeOIDCLoginRequired, "IdentifierIdentityManager: prompt=select_account request")
}
default:
// Let all other prompt values pass.
}
var auth identity.AuthRecord
// More checks.
if err == nil {
var sub string
if user != nil {
sub = user.Subject()
}
err = ar.Verify(sub)
if err != nil {
return nil, err
}
if user != nil {
record := identifier.NewRecord(req, im.identifier.Config.Config)
record.IdentifiedUser = user.IdentifiedUser
ctx = identifier.NewRecordContext(ctx, record)
// Inject required scopes into request.
for scope, ok := range user.RequiredScopes() {
ar.Scopes[scope] = ok
}
// Load user record from identitymanager, without any scopes or claims
// to ensure that the user data is refreshed and that the user still
// exists.
var found bool
auth, found, err = im.Fetch(ctx, user.Raw(), user.SessionRef(), nil, nil, ar.Scopes)
if !found {
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2ServerError, "user not found")
} else {
// Update ar.Scopes with the ones gotten from backend.
if bu, ok := auth.User().(*identifierUser); ok {
scopes := bu.Scopes()
if scopes != nil {
expanded := make(map[string]bool)
for _, scope := range scopes {
if enabled, ok := ar.Scopes[scope]; ok && !enabled {
// Skip already known but not enabled scopes.
continue
}
expanded[scope] = true
}
ar.Scopes = expanded
}
}
}
}
}
if err != nil {
if ar.Prompts[oidc.PromptNone] == true {
// Never show sign-in, directly return error.
return nil, err
}
// Build login URL.
query, err := url.ParseQuery(req.URL.RawQuery)
if err != nil {
return nil, err
}
query.Set("flow", identifier.FlowOIDC)
if ar.Claims != nil {
// Add derived scope list from claims request.
claimsScopes := ar.Claims.Scopes(ar.Scopes)
if len(claimsScopes) > 0 {
query.Set("claims_scope", strings.Join(claimsScopes, " "))
}
}
u, _ := url.Parse(im.signInFormURI)
u.RawQuery = query.Encode()
utils.WriteRedirect(rw, http.StatusFound, u, nil, false)
return nil, &identity.IsHandledError{}
}
if auth == nil {
// In case no existing user was fetched and that was not an error, make
// sure that we actually create a new auth record. This should not
// happen and is kept here for potential backwards compatibility.
auth = identity.NewAuthRecord(im, user.Subject(), nil, nil, nil)
auth.SetUser(user)
}
if loggedOn, logonAt := u.LoggedOn(); loggedOn {
auth.SetAuthTime(logonAt)
}
return auth, nil
}
// Authorize implements the identity.Manager interface.
func (im *IdentifierIdentityManager) Authorize(ctx context.Context, rw http.ResponseWriter, req *http.Request, ar *payload.AuthenticationRequest, auth identity.AuthRecord) (identity.AuthRecord, error) {
promptConsent := false
var approvedScopes map[string]bool
// Check prompt value.
switch {
case ar.Prompts[oidc.PromptConsent] == true:
promptConsent = true
default:
// Let all other prompt values pass.
}
origin := ""
if false {
// TODO(longsleep): find a condition when this can be enabled.
origin = utils.OriginFromRequestHeaders(req.Header)
}
clientDetails, err := im.clients.Lookup(req.Context(), ar.ClientID, "", ar.RedirectURI, origin, true)
if err != nil {
return nil, ar.NewError(oidc.ErrorCodeOAuth2AccessDenied, err.Error())
}
// If not trusted, always force consent.
if clientDetails.Trusted {
approvedScopes = ar.Scopes
} else {
promptConsent = true
}
// Check given consent.
consent, err := im.identifier.GetConsentFromConsentCookie(req.Context(), rw, req, req.Form.Get("konnect"))
if err != nil {
return nil, err
}
if consent != nil {
if !consent.Allow {
return auth, ar.NewError(oidc.ErrorCodeOAuth2AccessDenied, "consent denied")
}
promptConsent = false
filteredApprovedScopes, allApprovedScopes := consent.Scopes(ar.Scopes)
// Filter claims request by approved scopes.
if ar.Claims != nil {
err = ar.Claims.ApplyScopes(allApprovedScopes)
if err != nil {
return nil, err
}
}
approvedScopes = filteredApprovedScopes
}
if promptConsent {
if ar.Prompts[oidc.PromptNone] == true {
return auth, ar.NewError(oidc.ErrorCodeOIDCInteractionRequired, "consent required")
}
// Build consent URL.
query, err := url.ParseQuery(req.URL.RawQuery)
if err != nil {
return nil, err
}
query.Set("flow", identifier.FlowConsent)
if ar.Claims != nil {
// Add derived scope list from claims request.
claimsScopes := ar.Claims.Scopes(ar.Scopes)
if len(claimsScopes) > 0 {
query.Set("claims_scope", strings.Join(claimsScopes, " "))
}
}
if ar.Scopes != nil {
scopes := make([]string, 0)
for scope, ok := range ar.Scopes {
if ok {
scopes = append(scopes, scope)
}
query.Set("scope", strings.Join(scopes, " "))
}
}
u, _ := url.Parse(im.signInFormURI)
u.RawQuery = query.Encode()
utils.WriteRedirect(rw, http.StatusFound, u, nil, false)
return nil, &identity.IsHandledError{}
}
// Offline access validation.
// http://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
if ok, _ := approvedScopes[oidc.ScopeOfflineAccess]; ok {
var ignoreOfflineAccessErr error
for {
if ok, _ := ar.ResponseTypes[oidc.ResponseTypeCode]; !ok {
// MUST ignore the offline_access request unless the Client is using
// a response_type value that would result in an Authorization
// Code being returned,
ignoreOfflineAccessErr = fmt.Errorf("response_type=code required, %#v", ar.ResponseTypes)
break
}
if clientDetails.Trusted {
// Always allow offline access for trusted clients. This qualifies
// for other conditions.
break
}
if ok, _ := ar.Prompts[oidc.PromptConsent]; !ok && consent == nil {
// Ensure that the prompt parameter contains consent unless
// other conditions for processing the request permitting offline
// access to the requested resources are in place; unless one or
// both of these conditions are fulfilled, then it MUST ignore the
// offline_access request,
ignoreOfflineAccessErr = fmt.Errorf("prompt=consent required, %#v", ar.Prompts)
break
}
break
}
if ignoreOfflineAccessErr != nil {
delete(approvedScopes, oidc.ScopeOfflineAccess)
im.logger.WithError(ignoreOfflineAccessErr).Debugln("removed offline_access scope")
}
}
auth.AuthorizeScopes(approvedScopes)
auth.AuthorizeClaims(ar.Claims)
return auth, nil
}
// EndSession implements the identity.Manager interface.
func (im *IdentifierIdentityManager) EndSession(ctx context.Context, rw http.ResponseWriter, req *http.Request, esr *payload.EndSessionRequest) error {
var err error
var esrClaims *konnectoidc.IDTokenClaims
var clientDetails *clients.Details
origin := utils.OriginFromRequestHeaders(req.Header)
clientID := ""
if esr.IDTokenHint != nil {
// Extended request, verify IDTokenHint and its claims if available.
esrClaims = esr.IDTokenHint.Claims.(*konnectoidc.IDTokenClaims)
if len(esrClaims.Audience) == 1 {
clientID = esrClaims.Audience[0]
}
clientDetails, err = im.clients.Lookup(ctx, clientID, "", esr.PostLogoutRedirectURI, origin, true)
if err != nil {
// This error is not fatal since according to
// the spec in https://openid.net/specs/openid-connect-session-1_0.html#RPLogout the
// id_token_hint is not enforced to match the audience. Instead of fail
// we treat it as untrusted client.
im.logger.WithError(err).Debugln("IdentifierIdentityManager: id_token_hint does not match request")
esrClaims = nil
clientDetails = nil
}
}
var user *identifierUser
u, _ := im.identifier.GetUserFromLogonCookie(ctx, req, 0, false)
if u != nil {
user = asIdentifierUser(u)
// More checks.
if clientDetails != nil && user != nil {
sub := user.Subject()
err = esr.Verify(sub)
if err != nil {
return err
}
}
if clientDetails != nil && clientDetails.Trusted {
// Directly end identifier session when a trusted client requests
// and honor redirect wish if any.
var uri *url.URL
uri, err = im.identifier.EndSession(ctx, u, rw, esr.PostLogoutRedirectURI, esr.State)
if err != nil {
// Do nothing if err.
im.logger.WithError(err).Errorln("IdentifierIdentityManager: failed to end session")
return err
}
if uri != nil {
// Redirect to uri if end session returned any.
return identity.NewRedirectError("", uri)
}
}
} else {
// Ignore when not signed in, for end session.
}
if clientDetails == nil || !clientDetails.Trusted || esr.PostLogoutRedirectURI == nil || esr.PostLogoutRedirectURI.String() == "" {
// Handle directly by redirecting to our logout confirm url for untrusted
// clients or when no URL was set.
u, _ := url.Parse(im.signedOutURI)
query := &url.Values{}
if clientDetails != nil {
query.Add("flow", identifier.FlowOIDC)
}
if esrClaims != nil {
query.Add("client_id", clientID)
}
u.RawQuery = query.Encode()
return identity.NewRedirectError(oidc.ErrorCodeOIDCInteractionRequired, u)
}
return nil
}
// ApproveScopes implements the Backend interface.
func (im *IdentifierIdentityManager) ApproveScopes(ctx context.Context, sub string, audience string, approvedScopes map[string]bool) (string, error) {
ref := rndm.GenerateRandomString(32)
// TODO(longsleep): Store generated ref with provided data.
return ref, nil
}
// ApprovedScopes implements the Backend interface.
func (im *IdentifierIdentityManager) ApprovedScopes(ctx context.Context, sub string, audience string, ref string) (map[string]bool, error) {
if ref == "" {
return nil, fmt.Errorf("IdentifierIdentityManager: invalid ref")
}
return nil, nil
}
// Fetch implements the identity.Manager interface.
func (im *IdentifierIdentityManager) Fetch(ctx context.Context, userID string, sessionRef *string, scopes map[string]bool, requestedClaimsMaps []*payload.ClaimsRequestMap, requestedScopes map[string]bool) (identity.AuthRecord, bool, error) {
u, err := im.identifier.GetUserFromID(ctx, userID, sessionRef, requestedScopes)
if err != nil {
im.logger.WithError(err).Errorln("IdentifierIdentityManager: fetch failed to get user from userID")
return nil, false, fmt.Errorf("IdentifierIdentityManager: identifier error")
}
if u == nil {
return nil, false, fmt.Errorf("IdentifierIdentityManager: no user")
}
user := asIdentifierUser(u)
authorizedScopes, _ := identity.AuthorizeScopes(im, user, scopes)
claims := identity.GetUserClaimsForScopes(user, authorizedScopes, requestedClaimsMaps)
auth := identity.NewAuthRecord(im, user.Subject(), authorizedScopes, nil, claims)
auth.SetUser(user)
return auth, true, nil
}
// Name implements the identity.Manager interface.
func (im *IdentifierIdentityManager) Name() string {
return im.identifier.Name()
}
// ScopesSupported implements the identity.Manager interface.
func (im *IdentifierIdentityManager) ScopesSupported(scopes map[string]bool) []string {
scopesSupported := make([]string, len(im.scopesSupported))
copy(scopesSupported, im.scopesSupported)
for _, scope := range im.identifier.ScopesSupported() {
scopesSupported = append(scopesSupported, scope)
}
return scopesSupported
}
// ClaimsSupported implements the identity.Manager interface.
func (im *IdentifierIdentityManager) ClaimsSupported(claims []string) []string {
return im.claimsSupported
}
// AddRoutes implements the identity.Manager interface.
func (im *IdentifierIdentityManager) AddRoutes(ctx context.Context, router *mux.Router) {
im.identifier.AddRoutes(ctx, router)
}
// OnSetLogon implements the identity.Manager interface.
func (im *IdentifierIdentityManager) OnSetLogon(cb func(ctx context.Context, rw http.ResponseWriter, user identity.User) error) error {
return im.identifier.OnSetLogon(cb)
}
// OnUnsetLogon implements the identity.Manager interface.
func (im *IdentifierIdentityManager) OnUnsetLogon(cb func(ctx context.Context, rw http.ResponseWriter) error) error {
return im.identifier.OnUnsetLogon(cb)
}
+52
View File
@@ -0,0 +1,52 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package managers
import (
"encoding/base64"
"golang.org/x/crypto/blake2b"
konnectoidc "github.com/libregraph/lico/oidc"
)
func setupSupportedScopes(scopes []string, extra []string, override []string) []string {
if len(override) > 0 {
return override
}
return append(scopes, extra...)
}
func getPublicSubject(sub []byte, extra []byte) (string, error) {
// Hash the raw subject with our specific salt.
hasher, err := blake2b.New512([]byte(konnectoidc.LibreGraphIDTokenSubjectSaltV1))
if err != nil {
return "", err
}
hasher.Write(sub)
hasher.Write([]byte(" "))
hasher.Write(extra)
// NOTE(longsleep): URL safe encoding for subject is important since many
// third party applications validate this with rather strict patterns. We
// also inject an @ to ensure its compatible to some apps which require one.
s := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil))
return s[:16] + "@" + s[16:], nil
}
+84
View File
@@ -0,0 +1,84 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package identity
import (
"github.com/golang-jwt/jwt/v5"
)
// User defines a most simple user with an id defined as subject.
type User interface {
Subject() string
}
// UserWithEmail is a User with Email.
type UserWithEmail interface {
User
Email() string
EmailVerified() bool
}
// UserWithProfile is a User with Name.
type UserWithProfile interface {
User
Name() string
FamilyName() string
GivenName() string
}
// UserWithID is a User with a locally unique numeric id.
type UserWithID interface {
User
ID() int64
}
// UserWithUniqueID is a User with a unique string id.
type UserWithUniqueID interface {
User
UniqueID() string
}
// UserWithUsername is a User with an username different from subject.
type UserWithUsername interface {
User
Username() string
}
// UserWithClaims is a User with jwt claims.
type UserWithClaims interface {
User
Claims() jwt.MapClaims
}
// UserWithScopedClaims is a user with jwt claims bound to provided scopes.
type UserWithScopedClaims interface {
User
ScopedClaims(authorizedScopes map[string]bool) jwt.MapClaims
}
// UserWithSessionRef is a user which supports an underlaying session reference.
type UserWithSessionRef interface {
User
SessionRef() *string
}
// PublicUser is a user with a public Subject and a raw id.
type PublicUser interface {
Subject() string
Raw() string
}
+210
View File
@@ -0,0 +1,210 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package identity
import (
"fmt"
"github.com/golang-jwt/jwt/v5"
"github.com/libregraph/oidc-go"
konnectoidc "github.com/libregraph/lico/oidc"
"github.com/libregraph/lico/oidc/payload"
)
// AuthorizeScopes uses the provided manager and user to filter the provided
// scopes and returns a mapping of only the authorized scopes.
func AuthorizeScopes(manager Manager, user User, scopes map[string]bool) (map[string]bool, map[string]bool) {
if user == nil {
return nil, nil
}
authorizedScopes := make(map[string]bool)
unauthorizedScopes := make(map[string]bool)
supportedScopes := make(map[string]bool)
for _, scope := range manager.ScopesSupported(scopes) {
supportedScopes[scope] = true
}
for scope, authorizedScope := range scopes {
for {
if !authorizedScope {
// Incoming not authorized.
break
}
authorizedScope = isKnownScope(scope)
if !authorizedScope {
if _, ok := supportedScopes[scope]; ok {
authorizedScope = true
}
}
break
}
if authorizedScope {
authorizedScopes[scope] = true
} else {
unauthorizedScopes[scope] = false
}
}
return authorizedScopes, unauthorizedScopes
}
// GetUserClaimsForScopes returns a mapping of user claims of the provided user
// filtered by the provided scopes.
func GetUserClaimsForScopes(user User, scopes map[string]bool, requestedClaimsMaps []*payload.ClaimsRequestMap) map[string]jwt.Claims {
if user == nil {
return nil
}
claims := make(map[string]jwt.Claims)
if authorizedScope, _ := scopes[oidc.ScopeEmail]; authorizedScope {
if userWithEmail, ok := user.(UserWithEmail); ok {
claims[oidc.ScopeEmail] = &konnectoidc.EmailClaims{
Email: userWithEmail.Email(),
EmailVerified: userWithEmail.EmailVerified(),
}
}
}
if authorizedScope, _ := scopes[oidc.ScopeProfile]; authorizedScope {
var profileClaims *konnectoidc.ProfileClaims
if userWithProfile, ok := user.(UserWithProfile); ok {
profileClaims = &konnectoidc.ProfileClaims{
Name: userWithProfile.Name(),
FamilyName: userWithProfile.FamilyName(),
GivenName: userWithProfile.GivenName(),
}
}
if userWithUsername, ok := user.(UserWithUsername); ok {
if profileClaims == nil {
profileClaims = &konnectoidc.ProfileClaims{
PreferredUsername: userWithUsername.Username(),
}
} else {
profileClaims.PreferredUsername = userWithUsername.Username()
}
}
if profileClaims != nil {
claims[oidc.ScopeProfile] = profileClaims
}
}
// Add additional supported values for email and profile claims.
unknownRequestedClaimsWithValue := make(map[string]interface{})
for _, requestedClaimMap := range requestedClaimsMaps {
for requestedClaim, requestedClaimEntry := range *requestedClaimMap {
// NOTE(longsleep): We ignore the actuall value of the claim request
// and always return requested scopes with standard behavior.
if scope, ok := payload.GetScopeForClaim(requestedClaim); ok {
if authorizedScope, _ := scopes[scope]; !authorizedScope {
// Add claim values if known.
switch scope {
case oidc.ScopeEmail:
if userWithEmail, ok := user.(UserWithEmail); ok {
scopeClaims := konnectoidc.NewEmailClaims(claims[scope])
if scopeClaims == nil {
scopeClaims = &konnectoidc.EmailClaims{}
claims[scope] = scopeClaims
}
switch requestedClaim {
case oidc.EmailClaim:
scopeClaims.Email = userWithEmail.Email()
fallthrough // Always include EmailVerified claim.
case oidc.EmailVerifiedClaim:
scopeClaims.EmailVerified = userWithEmail.EmailVerified()
}
}
case oidc.ScopeProfile:
if userWithProfile, ok := user.(UserWithProfile); ok {
scopeClaims := konnectoidc.NewProfileClaims(claims[scope])
if scopeClaims == nil {
scopeClaims = &konnectoidc.ProfileClaims{}
claims[scope] = scopeClaims
}
switch requestedClaim {
case oidc.NameClaim:
scopeClaims.Name = userWithProfile.Name()
case oidc.FamilyNameClaim:
scopeClaims.Name = userWithProfile.FamilyName()
case oidc.GivenNameClaim:
scopeClaims.Name = userWithProfile.GivenName()
}
}
}
}
} else {
// Add claims which are unknown here to a list of unknown claims
// with value if the requested claim is with value. This returns
// the requested claim as is with the provided value.
if requestedClaimEntry != nil && requestedClaimEntry.Value != nil {
unknownRequestedClaimsWithValue[requestedClaim] = requestedClaimEntry.Value
}
}
}
}
// Add extra claims. Those can either come from the backend user if it
// has own scoped claims or might be defined as value by the request.
var claimsWithoutScope jwt.MapClaims
if userWithScopedClaims, ok := user.(UserWithScopedClaims); ok {
// Inject additional scope claims.
claimsWithoutScope = userWithScopedClaims.ScopedClaims(scopes)
}
if len(unknownRequestedClaimsWithValue) > 0 {
if claimsWithoutScope == nil {
claimsWithoutScope = make(jwt.MapClaims)
}
for claim, value := range unknownRequestedClaimsWithValue {
claimsWithoutScope[claim] = value
}
}
if claimsWithoutScope != nil {
claims[""] = claimsWithoutScope
}
return claims
}
// GetSessionRef builds a per user and audience unique identifier.
func GetSessionRef(label string, audience string, userID string) *string {
if userID == "" {
return nil
}
// NOTE(longsleep): For now we ignore the audience. Seems not to have any
// use to keep multiple sessions from Konnect per audience.
sessionRef := fmt.Sprintf("%s:-:%s", label, userID)
return &sessionRef
}
func isKnownScope(scope string) bool {
// Only authorize the scopes we know.
switch scope {
case oidc.ScopeOpenID:
default:
// Unknown scopes end up here and are not getting authorized.
return false
}
return true
}
+77
View File
@@ -0,0 +1,77 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package managers
import (
"fmt"
)
// ServiceUsesManagers is an interface for service which register to managers.
type ServiceUsesManagers interface {
RegisterManagers(mgrs *Managers) error
}
// Managers is a registry for named managers.
type Managers struct {
registry map[string]interface{}
}
// New creates a new Managers.
func New() *Managers {
return &Managers{
registry: make(map[string]interface{}),
}
}
// Set adds the provided manager with the provided name to the accociated
// Managers.
func (m *Managers) Set(name string, manager interface{}) {
m.registry[name] = manager
}
// Get returns the manager identified by the given name from the accociated
// managers.
func (m *Managers) Get(name string) (interface{}, bool) {
manager, ok := m.registry[name]
return manager, ok
}
// Must returns the manager indentified by the given name or panics.
func (m *Managers) Must(name string) interface{} {
manager, ok := m.Get(name)
if !ok {
panic(fmt.Errorf("manager %s not found", name))
}
return manager
}
// Apply registers the accociated manager's registered managers.
func (m *Managers) Apply() error {
for _, manager := range m.registry {
if service, ok := manager.(ServiceUsesManagers); ok {
err := service.RegisterManagers(m)
if err != nil {
return err
}
}
}
return nil
}
+87
View File
@@ -0,0 +1,87 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package oidc
import (
"github.com/golang-jwt/jwt/v5"
)
// IDTokenClaims define the claims found in OIDC ID Tokens.
type IDTokenClaims struct {
jwt.RegisteredClaims
Nonce string `json:"nonce,omitempty"`
AuthTime int64 `json:"auth_time,omitempty"`
AccessTokenHash string `json:"at_hash,omitempty"`
CodeHash string `json:"c_hash,omitempty"`
*ProfileClaims
*EmailClaims
*SessionClaims
}
// ProfileClaims define the claims for the OIDC profile scope.
// https://openid.net/specs/openid-connect-basic-1_0.html#Scopes
type ProfileClaims struct {
jwt.RegisteredClaims
Name string `json:"name,omitempty"`
FamilyName string `json:"family_name,omitempty"`
GivenName string `json:"given_name,omitempty"`
PreferredUsername string `json:"preferred_username,omitempty"`
}
// NewProfileClaims return a new ProfileClaims set from the provided
// jwt.Claims or nil.
func NewProfileClaims(claims jwt.Claims) *ProfileClaims {
if claims == nil {
return nil
}
return claims.(*ProfileClaims)
}
// EmailClaims define the claims for the OIDC email scope.
// https://openid.net/specs/openid-connect-basic-1_0.html#Scopes
type EmailClaims struct {
jwt.RegisteredClaims
Email string `json:"email,omitempty"`
EmailVerified bool `json:"email_verified"`
}
// NewEmailClaims return a new EmailClaims set from the provided
// jwt.Claims or nil.
func NewEmailClaims(claims jwt.Claims) *EmailClaims {
if claims == nil {
return nil
}
return claims.(*EmailClaims)
}
// UserInfoClaims define the claims defined by the OIDC UserInfo
// endpoint.
type UserInfoClaims struct {
Subject string `json:"sub,omitempty"`
}
// SessionClaims define claims related to front end sessions, for example as
// specified by https://openid.net/specs/openid-connect-frontchannel-1_0.html
type SessionClaims struct {
SessionID string `json:"sid,omitempty"`
}
+36
View File
@@ -0,0 +1,36 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package code
import (
"github.com/libregraph/lico/identity"
"github.com/libregraph/lico/oidc/payload"
)
// Record bundles the data storedi in a code manager.
type Record struct {
AuthenticationRequest *payload.AuthenticationRequest
Auth identity.AuthRecord
Session *payload.Session
}
// Manager is a interface defining a code manager.
type Manager interface {
Create(record *Record) (string, error)
Pop(code string) (*Record, bool)
}
@@ -0,0 +1,113 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package managers
import (
"context"
"time"
"github.com/longsleep/rndm"
"github.com/orcaman/concurrent-map"
"github.com/libregraph/lico/oidc/code"
)
const (
codeValidDuration = 2 * time.Minute
)
// Manager provides the api and state for OIDC code generation and token
// exchange. The CodeManager's methods are safe to call from multiple Go
// routines.
type memoryMapManager struct {
table cmap.ConcurrentMap
codeDuration time.Duration
}
type codeRequestRecord struct {
record *code.Record
//ar *payload.AuthenticationRequest
//auth identity.AuthRecord
when time.Time
}
// NewMemoryMapManager creates a new CodeManager.
func NewMemoryMapManager(ctx context.Context) code.Manager {
cm := &memoryMapManager{
table: cmap.New(),
}
// Cleanup function.
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
cm.purgeExpired()
case <-ctx.Done():
return
}
}
}()
return cm
}
func (cm *memoryMapManager) purgeExpired() {
var expired []string
deadline := time.Now().Add(-codeValidDuration)
var record *codeRequestRecord
for entry := range cm.table.IterBuffered() {
record = entry.Val.(*codeRequestRecord)
if record.when.Before(deadline) {
expired = append(expired, entry.Key)
}
}
for _, code := range expired {
cm.table.Remove(code)
}
}
// Create creates a new random code string, stores it together with the provided
// values in the accociated CodeManager's table and returns the code.
func (cm *memoryMapManager) Create(record *code.Record) (string, error) {
code := rndm.GenerateRandomString(24)
rr := &codeRequestRecord{
record: record,
when: time.Now(),
}
cm.table.Set(code, rr)
return code, nil
}
// Pop looks up the provided code in the accociated CodeManagers's table. If
// found it returns the authentication request and backend record plus true.
// When not found, both values return as nil plus false.
func (cm *memoryMapManager) Pop(code string) (*code.Record, bool) {
stored, found := cm.table.Pop(code)
if !found {
return nil, false
}
rr := stored.(*codeRequestRecord)
return rr.record, true
}
+81
View File
@@ -0,0 +1,81 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package oidc
import (
"fmt"
"net/http"
"github.com/libregraph/lico/utils"
)
// OAuth2Error defines a general OAuth2 error with id and decription.
type OAuth2Error struct {
ErrorID string `json:"error"`
ErrorDescription string `json:"error_description"`
}
// Error implements the error interface.
func (err *OAuth2Error) Error() string {
return err.ErrorID
}
// Description implements the ErrorWithDescription interface.
func (err *OAuth2Error) Description() string {
return err.ErrorDescription
}
// NewOAuth2Error creates a new error with id and description.
func NewOAuth2Error(id string, description string) utils.ErrorWithDescription {
return &OAuth2Error{id, description}
}
// WriteWWWAuthenticateError writes the provided error with the provided
// http status code to the provided http response writer as a
// WWW-Authenticate header with comma separated fields for id and
// description.
func WriteWWWAuthenticateError(rw http.ResponseWriter, code int, err error) {
if code == 0 {
code = http.StatusUnauthorized
}
var description string
switch err.(type) {
case utils.ErrorWithDescription:
description = err.(utils.ErrorWithDescription).Description()
default:
}
rw.Header().Set("WWW-Authenticate", fmt.Sprintf("error=\"%s\", error_description=\"%s\"", err.Error(), description))
rw.WriteHeader(code)
}
// IsErrorWithID returns true if the given error is an OAuth2Error error with
// the given ID.
func IsErrorWithID(err error, id string) bool {
if err == nil {
return false
}
oauth2Error, ok := err.(*OAuth2Error)
if !ok {
return false
}
return oauth2Error.ErrorID == id
}
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package oidc
// LibreGraphIDTokenSubjectSaltV1 is the salt value used when hashing Subjects
// in ID tokens created by this application.
const LibreGraphIDTokenSubjectSaltV1 = "lico-IDToken-v1"
+462
View File
@@ -0,0 +1,462 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package payload
import (
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/libregraph/oidc-go"
konnectoidc "github.com/libregraph/lico/oidc"
)
// AuthenticationRequest holds the incoming parameters and request data for
// the OpenID Connect 1.0 authorization endpoint as specified at
// http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest and
// http://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthRequest
type AuthenticationRequest struct {
providerMetadata *oidc.WellKnown
RawScope string `schema:"scope"`
Claims *ClaimsRequest `schema:"claims"`
RawResponseType string `schema:"response_type"`
ResponseMode string `schema:"response_mode"`
ClientID string `schema:"client_id"`
RawRedirectURI string `schema:"redirect_uri"`
State string `schema:"state"`
Nonce string `schema:"nonce"`
RawPrompt string `schema:"prompt"`
RawIDTokenHint string `schema:"id_token_hint"`
RawMaxAge string `schema:"max_age"`
RawRequest string `schema:"request"`
RawRequestURI string `schema:"request_uri"`
RawRegistration string `schema:"registration"`
CodeChallenge string `schema:"code_challenge"`
CodeChallengeMethod string `schema:"code_challenge_method"`
Scopes map[string]bool `schema:"-"`
ResponseTypes map[string]bool `schema:"-"`
Prompts map[string]bool `schema:"-"`
RedirectURI *url.URL `schema:"-"`
IDTokenHint *jwt.Token `schema:"-"`
MaxAge time.Duration `schema:"-"`
Request *jwt.Token `schema:"-"`
UseFragment bool `schema:"-"`
Flow string `schema:"-"`
Session *Session `schema:"-"`
}
// DecodeAuthenticationRequest returns a AuthenticationRequest holding the
// provided requests form data.
func DecodeAuthenticationRequest(req *http.Request, providerMetadata *oidc.WellKnown, keyFunc jwt.Keyfunc) (*AuthenticationRequest, error) {
return NewAuthenticationRequest(req.Form, providerMetadata, keyFunc)
}
// NewAuthenticationRequest returns a AuthenticationRequest holding the
// provided url values.
func NewAuthenticationRequest(values url.Values, providerMetadata *oidc.WellKnown, keyFunc jwt.Keyfunc) (*AuthenticationRequest, error) {
ar := &AuthenticationRequest{
providerMetadata: providerMetadata,
Scopes: make(map[string]bool),
ResponseTypes: make(map[string]bool),
Prompts: make(map[string]bool),
}
err := DecodeSchema(ar, values)
if err != nil {
return nil, fmt.Errorf("failed to decode authentication request: %v", err)
}
if ar.RawScope != "" {
// Parse scope early, since the value is needed to handle the request
// parameter properly.
for _, scope := range strings.Split(ar.RawScope, " ") {
ar.Scopes[scope] = true
}
}
if ar.RawRequest != "" {
parser := &jwt.Parser{}
request, err := parser.ParseWithClaims(ar.RawRequest, &RequestObjectClaims{}, func(token *jwt.Token) (interface{}, error) {
if keyFunc != nil {
return keyFunc(token)
}
return nil, fmt.Errorf("Not validated")
})
if err != nil {
return nil, ar.NewBadRequest(oidc.ErrorCodeOIDCInvalidRequestObject, err.Error())
}
if claims, ok := request.Claims.(*RequestObjectClaims); ok {
err = ar.ApplyRequestObject(claims, request.Method)
if err != nil {
return nil, err
}
}
ar.Request = request
}
ar.RedirectURI, _ = url.Parse(ar.RawRedirectURI)
if ar.RawResponseType != "" {
for _, rt := range strings.Split(ar.RawResponseType, " ") {
ar.ResponseTypes[rt] = true
}
}
if ar.RawPrompt != "" {
for _, prompt := range strings.Split(ar.RawPrompt, " ") {
ar.Prompts[prompt] = true
}
}
switch ar.RawResponseType {
case oidc.ResponseTypeCode:
// Code flow.
ar.Flow = oidc.FlowCode
// breaks
case oidc.ResponseTypeIDToken:
// Implicit flow.
fallthrough
case oidc.ResponseTypeIDTokenToken:
// Implicit flow with access token.
ar.UseFragment = true
ar.Flow = oidc.FlowImplicit
case oidc.ResponseTypeCodeIDToken:
// Hybrid flow.
fallthrough
case oidc.ResponseTypeCodeToken:
// Hybgrid flow.
fallthrough
case oidc.ResponseTypeCodeIDTokenToken:
// Hybrid flow.
ar.UseFragment = true
ar.Flow = oidc.FlowHybrid
}
switch ar.ResponseMode {
case oidc.ResponseModeFragment:
ar.UseFragment = true
// breaks
case oidc.ResponseModeQuery:
ar.UseFragment = false
// breaks
}
if ar.RawMaxAge != "" {
maxAgeInt, err := strconv.ParseInt(ar.RawMaxAge, 10, 64)
if err != nil {
return nil, err
}
ar.MaxAge = time.Duration(maxAgeInt) * time.Second
}
if ar.Claims != nil && ar.Claims.Passthru != nil {
// Remove pass thru claims when not provided in a secure manner. This
// means that pass through claims can only be passed via a signed request
// objects and its claims.
if ar.Request == nil || ar.Request.Method == jwt.SigningMethodNone || ar.Request.Claims == nil {
ar.Claims.Passthru = nil
}
}
return ar, nil
}
// ApplyRequestObject applies the provided request object claims to the
// associated authentication request data with validation as required.
func (ar *AuthenticationRequest) ApplyRequestObject(roc *RequestObjectClaims, method jwt.SigningMethod) error {
// Basic consistency validation following spec at
// https://openid.net/specs/openid-connect-core-1_0.html#SignedRequestObject
if ok := ar.Scopes[oidc.ScopeOpenID]; !ok {
return ar.NewBadRequest(oidc.ErrorCodeOIDCInvalidRequestObject, "openid scope required when using the request parameter")
}
if roc.RawScope != "" {
ar.Scopes = make(map[string]bool)
// Parse scope directly, since the accociated authentication request
// has already parsed it when this is called.
for _, scope := range strings.Split(roc.RawScope, " ") {
ar.Scopes[scope] = true
}
}
if roc.RawResponseType != "" {
if roc.RawResponseType != ar.RawResponseType {
return ar.NewBadRequest(oidc.ErrorCodeOIDCInvalidRequestObject, "request object response_type mismatch")
}
}
if roc.ClientID != "" {
if roc.ClientID != ar.ClientID {
return ar.NewBadRequest(oidc.ErrorCodeOIDCInvalidRequestObject, "request object client_id mismatch")
}
}
if method != jwt.SigningMethodNone {
// Additional claim validation when signed. The spec says that iss and
// aud SHOULD have defined values. So for now we do not enforce here.
}
// Apply rest of the provided request object values to the accociated
// authentication request.
if roc.Claims != nil {
// NOTE(longsleep): Overwrite request claims with the signed claims
// from the request object. This ensures that only signed claims are
// processed if any have been given. If no signed claims have been
// given, the unsigned claims are kept, leaving it to further checks
// to ensure that only signed claims are used by checking that the
// roc object has claims.
ar.Claims = roc.Claims
}
if roc.RawRedirectURI != "" {
ar.RawRedirectURI = roc.RawRedirectURI
}
if roc.State != "" {
ar.State = roc.State
}
if roc.Nonce != "" {
ar.Nonce = roc.Nonce
}
if roc.RawPrompt != "" {
ar.RawPrompt = roc.RawPrompt
}
if roc.RawIDTokenHint != "" {
ar.RawIDTokenHint = roc.RawIDTokenHint
}
if roc.RawMaxAge != "" {
ar.RawMaxAge = roc.RawMaxAge
}
if roc.RawRegistration != "" {
ar.RawRegistration = roc.RawRegistration
}
if roc.CodeChallengeMethod != "" {
ar.CodeChallengeMethod = roc.CodeChallengeMethod
}
if roc.CodeChallenge != "" {
ar.CodeChallenge = roc.CodeChallenge
}
return nil
}
// Validate validates the request data of the accociated authentication request.
func (ar *AuthenticationRequest) Validate(keyFunc jwt.Keyfunc) error {
switch ar.RawResponseType {
case oidc.ResponseTypeCode:
// Code flow.
// breaks
case oidc.ResponseTypeCodeIDToken:
// Hybgrid flow.
if _, ok := ar.Scopes[oidc.ScopeOpenID]; !ok {
return ar.NewBadRequest(oidc.ErrorCodeOAuth2InvalidRequest, "missing openid scope in request")
}
// breaks
case oidc.ResponseTypeCodeToken:
// Hybgrid flow.
// breaks
case oidc.ResponseTypeCodeIDTokenToken:
// Hybgrid flow.
if _, ok := ar.Scopes[oidc.ScopeOpenID]; !ok {
return ar.NewBadRequest(oidc.ErrorCodeOAuth2InvalidRequest, "missing openid scope in request")
}
// breaks
case oidc.ResponseTypeIDToken:
// Implicit flow.
if _, ok := ar.Scopes[oidc.ScopeOpenID]; !ok {
return ar.NewBadRequest(oidc.ErrorCodeOAuth2InvalidRequest, "missing openid scope in request")
}
fallthrough
case oidc.ResponseTypeIDTokenToken:
// Implicit flow with access token.
if _, ok := ar.Scopes[oidc.ScopeOpenID]; !ok {
return ar.NewBadRequest(oidc.ErrorCodeOAuth2InvalidRequest, "missing openid scope in request")
}
if ar.Nonce == "" {
return ar.NewError(oidc.ErrorCodeOAuth2InvalidRequest, "nonce is required for implicit flow")
}
case oidc.ResponseTypeToken:
// OAuth2 flow implicit grant.
// breaks
default:
return ar.NewError(oidc.ErrorCodeOAuth2UnsupportedResponseType, "")
}
// Additional checks for flows with code.
if ar.Flow == oidc.FlowCode || ar.Flow == oidc.FlowHybrid {
switch ar.CodeChallengeMethod {
case "":
// breaks
case oidc.S256CodeChallengeMethod:
// breaks
case oidc.PlainCodeChallengeMethod:
// Plain is discouraged, and thus not supported.
fallthrough
default:
return ar.NewBadRequest(oidc.ErrorCodeOAuth2InvalidRequest, "transform algorithm not supported")
}
}
if _, hasNonePrompt := ar.Prompts[oidc.PromptNone]; hasNonePrompt {
if len(ar.Prompts) > 1 {
// Cannot have other prompts if none is requested.
return ar.NewError(oidc.ErrorCodeOAuth2InvalidRequest, "cannot request other prompts together with none")
}
}
if ar.ClientID == "" {
return ar.NewBadRequest(oidc.ErrorCodeOAuth2InvalidRequest, "missing client_id")
}
// TODO(longsleep): implement client_id white list.
if ar.RedirectURI == nil || !ar.RedirectURI.IsAbs() {
return ar.NewBadRequest(oidc.ErrorCodeOAuth2InvalidRequest, "invalid or missing redirect_uri")
}
if ar.RawIDTokenHint != "" {
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
idTokenHint, err := parser.ParseWithClaims(ar.RawIDTokenHint, &konnectoidc.IDTokenClaims{}, func(token *jwt.Token) (interface{}, error) {
if keyFunc != nil {
return keyFunc(token)
}
return nil, fmt.Errorf("Not validated")
})
if err != nil {
return ar.NewBadRequest(oidc.ErrorCodeOAuth2InvalidRequest, err.Error())
}
ar.IDTokenHint = idTokenHint
}
// Offline access validation.
// http://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
if ok, _ := ar.Scopes[oidc.ScopeOfflineAccess]; ok {
if _, withCodeResponseType := ar.ResponseTypes[oidc.ResponseTypeCode]; !withCodeResponseType {
// Ignore the offline_access request unless the Client is using a
// response_type value that would result in an Authorization Code
// being returned.
delete(ar.Scopes, oidc.ScopeOfflineAccess)
}
}
if ar.RawRequestURI != "" {
return ar.NewError(oidc.ErrorCodeOIDCRequestURINotSupported, "")
}
if ar.RawRegistration != "" {
return ar.NewError(oidc.ErrorCodeOIDCRegistrationNotSupported, "")
}
return nil
}
// Verify checks that the passed parameters match the accociated requirements.
func (ar *AuthenticationRequest) Verify(userID string) error {
if ar.IDTokenHint != nil {
// Compare userID with IDTokenHint.
if userID != ar.IDTokenHint.Claims.(*konnectoidc.IDTokenClaims).Subject {
return ar.NewError(oidc.ErrorCodeOIDCLoginRequired, "userid mismatch")
}
}
return nil
}
// NewError creates a new error with id and string and the associated request's
// state.
func (ar *AuthenticationRequest) NewError(id string, description string) *AuthenticationError {
return &AuthenticationError{
ErrorID: id,
ErrorDescription: description,
State: ar.State,
}
}
// NewBadRequest creates a new error with id and string and the associated
// request's state.
func (ar *AuthenticationRequest) NewBadRequest(id string, description string) *AuthenticationBadRequest {
return &AuthenticationBadRequest{
ErrorID: id,
ErrorDescription: description,
State: ar.State,
}
}
// AuthenticationSuccess holds the outgoind data for a successful OpenID
// Connect 1.0 authorize request as specified at
// http://openid.net/specs/openid-connect-core-1_0.html#AuthResponse and
// http://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthResponse.
// https://openid.net/specs/openid-connect-session-1_0.html#CreatingUpdatingSessions
type AuthenticationSuccess struct {
Code string `url:"code,omitempty"`
AccessToken string `url:"access_token,omitempty"`
TokenType string `url:"token_type,omitempty"`
IDToken string `url:"id_token,omitempty"`
State string `url:"state"`
ExpiresIn int64 `url:"expires_in,omitempty"`
Scope string `url:"scope,omitempty"`
SessionState string `url:"session_state,omitempty"`
}
// AuthenticationError holds the outgoind data for a failed OpenID
// Connect 1.0 authorize request as specified at
// http://openid.net/specs/openid-connect-core-1_0.html#AuthError and
// http://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthError.
type AuthenticationError struct {
ErrorID string `url:"error" json:"error"`
ErrorDescription string `url:"error_description,omitempty" json:"error_description,omitempty"`
State string `url:"state,omitempty" json:"state,omitempty"`
}
// Error interface implementation.
func (ae *AuthenticationError) Error() string {
return ae.ErrorID
}
// Description implements ErrorWithDescription interface.
func (ae *AuthenticationError) Description() string {
return ae.ErrorDescription
}
// AuthenticationBadRequest holds the outgoing data for a failed OpenID Connect
// 1.0 authorize request with bad request parameters which make it impossible to
// continue with normal auth.
type AuthenticationBadRequest struct {
ErrorID string `url:"error" json:"error"`
ErrorDescription string `url:"error_description,omitempty" json:"error_description,omitempty"`
State string `url:"state,omitempty" json:"state,omitempty"`
}
// Error interface implementation.
func (ae *AuthenticationBadRequest) Error() string {
return ae.ErrorID
}
// Description implements ErrorWithDescription interface.
func (ae *AuthenticationBadRequest) Description() string {
return ae.ErrorDescription
}
+193
View File
@@ -0,0 +1,193 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package payload
import (
"encoding/json"
"strings"
"github.com/libregraph/oidc-go"
)
var scopedClaims = map[string]string{
oidc.NameClaim: oidc.ScopeProfile,
oidc.FamilyNameClaim: oidc.ScopeProfile,
oidc.GivenNameClaim: oidc.ScopeProfile,
oidc.MiddleNameClaim: oidc.ScopeProfile,
oidc.PreferredUsernameClaim: oidc.ScopeProfile,
oidc.ProfileClaim: oidc.ScopeProfile,
oidc.PictureClaim: oidc.ScopeProfile,
oidc.WebsiteClaim: oidc.ScopeProfile,
oidc.GenderClaim: oidc.ScopeProfile,
oidc.BirthdateClaim: oidc.ScopeProfile,
oidc.ZoneinfoClaim: oidc.ScopeProfile,
oidc.UpdatedAtClaim: oidc.ScopeProfile,
oidc.EmailClaim: oidc.ScopeEmail,
oidc.EmailVerifiedClaim: oidc.ScopeEmail,
}
// GetScopeForClaim returns the known scope if any for the provided claim name.
func GetScopeForClaim(claim string) (string, bool) {
scope, ok := scopedClaims[claim]
return scope, ok
}
// ClaimsRequest define the base claims structure for OpenID Connect claims
// request parameter value as specified at
// https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter - in
// addition a Konnect specific pass thru value can be used to pass through any
// application specific values to access and reqfresh tokens.
type ClaimsRequest struct {
UserInfo *ClaimsRequestMap `json:"userinfo,omitempty"`
IDToken *ClaimsRequestMap `json:"id_token,omitempty"`
Passthru json.RawMessage `json:"passthru,omitempty"`
}
// ApplyScopes removes all claims requests from the accociated claims request
// which are not mapped to one of the provided approved scopes.
func (cr *ClaimsRequest) ApplyScopes(approvedScopes map[string]bool) error {
if cr.UserInfo != nil {
for claim := range *cr.UserInfo {
if approved := approvedScopes[scopedClaims[claim]]; !approved {
delete(*cr.UserInfo, claim)
}
}
}
if cr.IDToken != nil {
for claim := range *cr.IDToken {
if approved := approvedScopes[scopedClaims[claim]]; !approved {
delete(*cr.IDToken, claim)
}
}
}
return nil
}
// Scopes adds all scopes of the accociated claims requests claims to
// the provied scopes mapping safe the scopes already defined in the provided
// excluded scopes mapping.
func (cr *ClaimsRequest) Scopes(excludedScopes map[string]bool) []string {
scopesMap := make(map[string]bool)
if cr.UserInfo != nil {
for claim := range *cr.UserInfo {
scope := scopedClaims[claim]
if _, excluded := excludedScopes[scope]; !excluded {
scopesMap[scope] = true
}
}
}
if cr.IDToken != nil {
for claim := range *cr.IDToken {
scope := scopedClaims[claim]
if _, excluded := excludedScopes[scope]; !excluded {
scopesMap[scope] = true
}
}
}
scopes := make([]string, 0)
for scope := range scopesMap {
scopes = append(scopes, scope)
}
return scopes
}
// ClaimsRequestMap defines a mapping of claims request values used with
// OpenID Connect claims request parameter values.
type ClaimsRequestMap map[string]*ClaimsRequestValue
// ScopesMap returns a map of scopes defined by the claims in tha associated map.
func (crm *ClaimsRequestMap) ScopesMap(excludedScopes map[string]bool) map[string]bool {
scopesMap := make(map[string]bool)
for claim := range *crm {
scope := scopedClaims[claim]
if _, excluded := excludedScopes[scope]; !excluded {
scopesMap[scope] = true
}
}
return scopesMap
}
// Get returns the accociated maps claim value identified by the provided name.
func (crm ClaimsRequestMap) Get(claim string) (*ClaimsRequestValue, bool) {
value, ok := crm[claim]
return value, ok
}
// GetStringValue returns the accociated maps claim value identified by the
// provided name as string value.
func (crm ClaimsRequestMap) GetStringValue(claim string) (string, bool) {
value, ok := crm.Get(claim)
if !ok {
return "", false
}
s, ok := value.Value.(string)
return s, ok
}
// ClaimsRequestValue is the claims request detail definition of an OpenID
// Connect claims request parameter value.
type ClaimsRequestValue struct {
Essential bool `json:"essential,omitempty"`
Value interface{} `json:"value,omitempty"`
Values []interface{} `json:"values,omitempty"`
}
// Match returns true of the provided value is contained inside the accociated
// request values values or value.
func (crv *ClaimsRequestValue) Match(value interface{}) bool {
if len(crv.Values) == 0 {
return value == crv.Value
}
for _, v := range crv.Values {
if v == value {
return true
}
}
return false
}
// ScopesValue is a string array with JSON marshal to/from a space separated
// single string value.
type ScopesValue []string
func (sv ScopesValue) MarshalJSON() ([]byte, error) {
result := strings.Join(sv, " ")
return json.Marshal(&result)
}
func (sv *ScopesValue) UnmarshalJSON(data []byte) error {
var parsed string
err := json.Unmarshal(data, &parsed)
if err != nil {
return err
}
result := ScopesValue(strings.Split(parsed, " "))
*sv = result
return nil
}
+132
View File
@@ -0,0 +1,132 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package payload
import (
"fmt"
"net/http"
"net/url"
"github.com/golang-jwt/jwt/v5"
"github.com/libregraph/oidc-go"
konnectoidc "github.com/libregraph/lico/oidc"
)
// EndSessionRequest holds the incoming parameters and request data for OpenID
// Connect Session Management 1.0 RP initiaed logout requests as specified at
// https://openid.net/specs/openid-connect-session-1_0.html#RPLogout
type EndSessionRequest struct {
providerMetadata *oidc.WellKnown
RawIDTokenHint string `schema:"id_token_hint"`
RawPostLogoutRedirectURI string `schema:"post_logout_redirect_uri"`
State string `schema:"state"`
IDTokenHint *jwt.Token `schema:"-"`
PostLogoutRedirectURI *url.URL `schema:"-"`
}
// DecodeEndSessionRequest returns a EndSessionRequest holding the
// provided requests form data.
func DecodeEndSessionRequest(req *http.Request, providerMetadata *oidc.WellKnown) (*EndSessionRequest, error) {
return NewEndSessionRequest(req.Form, providerMetadata)
}
// NewEndSessionRequest returns a EndSessionRequest holding the
// provided url values.
func NewEndSessionRequest(values url.Values, providerMetadata *oidc.WellKnown) (*EndSessionRequest, error) {
esr := &EndSessionRequest{
providerMetadata: providerMetadata,
}
err := DecodeSchema(esr, values)
if err != nil {
return nil, err
}
esr.PostLogoutRedirectURI, _ = url.Parse(esr.RawPostLogoutRedirectURI)
return esr, nil
}
// Validate validates the request data of the accociated endSession request.
func (esr *EndSessionRequest) Validate(keyFunc jwt.Keyfunc) error {
if esr.RawIDTokenHint != "" {
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
idTokenHint, err := parser.ParseWithClaims(esr.RawIDTokenHint, &konnectoidc.IDTokenClaims{}, func(token *jwt.Token) (interface{}, error) {
if keyFunc != nil {
return keyFunc(token)
}
return nil, fmt.Errorf("Not validated")
})
if err != nil {
return esr.NewBadRequest(oidc.ErrorCodeOAuth2InvalidRequest, err.Error())
}
esr.IDTokenHint = idTokenHint
}
return nil
}
// Verify checks that the passed parameters match the accociated requirements.
func (esr *EndSessionRequest) Verify(userID string) error {
if esr.IDTokenHint != nil {
// Compare userID with IDTokenHint.
if userID != esr.IDTokenHint.Claims.(*konnectoidc.IDTokenClaims).Subject {
return esr.NewBadRequest(oidc.ErrorCodeOAuth2InvalidRequest, "userid mismatch")
}
}
return nil
}
// NewError creates a new error with id and string and the associated request's
// state.
func (esr *EndSessionRequest) NewError(id string, description string) *AuthenticationError {
return &AuthenticationError{
ErrorID: id,
ErrorDescription: description,
State: esr.State,
}
}
// NewBadRequest creates a new error with id and string and the associated
// request's state.
func (esr *EndSessionRequest) NewBadRequest(id string, description string) *AuthenticationBadRequest {
return &AuthenticationBadRequest{
ErrorID: id,
ErrorDescription: description,
State: esr.State,
}
}
func (esr *EndSessionRequest) MakeRedirectEndSessionRequestURL() *url.URL {
if esr.PostLogoutRedirectURI == nil || esr.PostLogoutRedirectURI.String() == "" {
return nil
}
if esr.State == "" {
return esr.PostLogoutRedirectURI
}
uri, _ := url.Parse(esr.PostLogoutRedirectURI.String())
query := uri.Query()
query.Add("state", esr.State)
uri.RawQuery = query.Encode()
return uri
}
+315
View File
@@ -0,0 +1,315 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package payload
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/golang-jwt/jwt/v5"
"github.com/libregraph/oidc-go"
"github.com/mendsley/gojwk"
"github.com/libregraph/lico/identity/clients"
konnectoidc "github.com/libregraph/lico/oidc"
)
// ClientRegistrationRequest holds the incoming request data for the OpenID
// Connect Dynamic Client Registration 1.0 client registration endpoint as
// specified at https://openid.net/specs/openid-connect-registration-1_0.html#ClientRegistration and
// https://openid.net/specs/openid-connect-session-1_0.html#DynRegRegistrations
type ClientRegistrationRequest struct {
RedirectURIs []string `json:"redirect_uris"`
ResponseTypes []string `json:"response_types"`
GrantTypes []string `json:"grant_types"`
ApplicationType string `json:"application_type"`
Contacts []string `json:"contacts"`
ClientName string `json:"client_name"`
ClientURI string `json:"client_uri"`
RawJWKS json.RawMessage `json:"jwks"`
RawIDTokenSignedResponseAlg string `json:"id_token_signed_response_alg"`
RawUserInfoSignedResponseAlg string `json:"userinfo_signed_response_alg"`
RawRequestObjectSigningAlg string `json:"request_object_signing_alg"`
RawTokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
RawTokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"`
PostLogoutRedirectURIs []string `json:"post_logout_redirect_uris"`
JWKS *gojwk.Key `json:"-"`
}
// DecodeClientRegistrationRequest returns a ClientRegistrationRequest holding
// the provided request's data.
func DecodeClientRegistrationRequest(req *http.Request) (*ClientRegistrationRequest, error) {
contentType := req.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "application/json") {
return nil, fmt.Errorf("invalid content-type")
}
decoder := json.NewDecoder(req.Body)
var crr ClientRegistrationRequest
err := decoder.Decode(&crr)
if err != nil {
return nil, fmt.Errorf("failed to decode client registration request: %v", err)
}
if crr.RawJWKS != nil {
jwks, err := gojwk.Unmarshal(crr.RawJWKS)
if err != nil {
return nil, fmt.Errorf("failed to decode client registration request jwks: %v", err)
}
// Only use keys.
crr.JWKS = &gojwk.Key{
Keys: jwks.Keys,
}
}
return &crr, err
}
// Validate validates the request data of the accociated client registration
// request and fills in default data where required.
func (crr *ClientRegistrationRequest) Validate() error {
if len(crr.RedirectURIs) == 0 {
return konnectoidc.NewOAuth2Error(oidc.ErrorCodeOIDCInvalidRedirectURI, "redirect_uris required")
}
// Validate and filter response_type.
if len(crr.ResponseTypes) == 0 {
crr.ResponseTypes = []string{oidc.ResponseTypeCode}
}
requiredGrantTypes := make(map[string]bool)
responseTypes := make([]string, 0)
for _, responseType := range crr.ResponseTypes {
switch responseType {
case oidc.ResponseTypeCode:
requiredGrantTypes[oidc.GrantTypeAuthorizationCode] = true
responseTypes = append(responseTypes, responseType)
// breaks
case oidc.ResponseTypeCodeIDToken:
fallthrough
case oidc.ResponseTypeCodeIDTokenToken:
fallthrough
case oidc.ResponseTypeCodeToken:
requiredGrantTypes[oidc.GrantTypeAuthorizationCode] = true
requiredGrantTypes[oidc.GrantTypeImplicit] = true
responseTypes = append(responseTypes, responseType)
// breaks
case oidc.ResponseTypeIDToken:
fallthrough
case oidc.ResponseTypeIDTokenToken:
requiredGrantTypes[oidc.GrantTypeAuthorizationCode] = true
requiredGrantTypes[oidc.GrantTypeImplicit] = true
responseTypes = append(responseTypes, responseType)
// breaks
case oidc.ResponseTypeToken:
responseTypes = append(responseTypes, responseType)
default:
}
}
crr.ResponseTypes = responseTypes
// Filter and validate grant_types.
if len(crr.GrantTypes) == 0 {
crr.GrantTypes = []string{oidc.GrantTypeAuthorizationCode}
}
grantTypes := make([]string, 0)
registeredGrantTypes := make(map[string]bool)
for _, grantType := range crr.GrantTypes {
switch grantType {
case oidc.GrantTypeAuthorizationCode:
fallthrough
case oidc.GrantTypeImplicit:
fallthrough
case oidc.GrantTypeRefreshToken:
registeredGrantTypes[grantType] = true
grantTypes = append(grantTypes, grantType)
default:
}
}
for grantType := range requiredGrantTypes {
if ok := registeredGrantTypes[grantType]; !ok {
return konnectoidc.NewOAuth2Error(oidc.ErrorCodeOIDCInvalidClientMetadata, "grant_types conflict with response_types")
}
}
if crr.ApplicationType == "" {
crr.ApplicationType = oidc.ApplicationTypeWeb
}
switch crr.ApplicationType {
case oidc.ApplicationTypeWeb:
// Web Clients using the OAuth Implicit Grant Type MUST only register
// URLs using the https scheme as redirect_uris; they MUST NOT use
// localhost as the hostname.
for _, uriString := range crr.RedirectURIs {
uri, err := url.Parse(uriString)
if err != nil {
return konnectoidc.NewOAuth2Error(oidc.ErrorCodeOIDCInvalidRedirectURI, "failed to parse redirect_uris")
}
if ok := registeredGrantTypes[oidc.GrantTypeImplicit]; ok {
if uri.Scheme != "https" {
return konnectoidc.NewOAuth2Error(oidc.ErrorCodeOIDCInvalidRedirectURI, "web clients must use https redirect_uris")
}
if clients.IsLocalNativeHostURI(uri) {
return konnectoidc.NewOAuth2Error(oidc.ErrorCodeOIDCInvalidRedirectURI, "web clients must not use localhost redirect_uris")
}
}
}
case oidc.ApplicationTypeNative:
// Native Clients MUST only register redirect_uris using custom URI
// schemes or URLs using the http: scheme with localhost as the hostname.
for _, uriString := range crr.RedirectURIs {
uri, err := url.Parse(uriString)
if err != nil {
return konnectoidc.NewOAuth2Error(oidc.ErrorCodeOIDCInvalidRedirectURI, "failed to parse redirect_uris")
}
if !clients.IsLocalNativeHTTPURI(uri) {
return konnectoidc.NewOAuth2Error(oidc.ErrorCodeOIDCInvalidRedirectURI, "native clients must only use localhost redirect_uris with http")
}
}
default:
return konnectoidc.NewOAuth2Error(oidc.ErrorCodeOIDCInvalidClientMetadata, "unknown application_type")
}
if crr.RawIDTokenSignedResponseAlg == "" {
crr.RawIDTokenSignedResponseAlg = jwt.SigningMethodRS256.Alg()
}
if crr.RawIDTokenSignedResponseAlg != "" {
alg := jwt.GetSigningMethod(crr.RawIDTokenSignedResponseAlg)
if alg == nil {
return konnectoidc.NewOAuth2Error(oidc.ErrorCodeOIDCInvalidClientMetadata, "unknown id_token_signed_response_alg")
}
}
if crr.RawUserInfoSignedResponseAlg != "" {
alg := jwt.GetSigningMethod(crr.RawUserInfoSignedResponseAlg)
if alg == nil {
return konnectoidc.NewOAuth2Error(oidc.ErrorCodeOIDCInvalidClientMetadata, "unknown userinfo_signed_response_alg")
}
}
if crr.RawRequestObjectSigningAlg != "" {
alg := jwt.GetSigningMethod(crr.RawRequestObjectSigningAlg)
if alg == nil {
return konnectoidc.NewOAuth2Error(oidc.ErrorCodeOIDCInvalidClientMetadata, "unknown request_object_signing_alg")
}
}
if crr.RawTokenEndpointAuthMethod == "" {
crr.RawTokenEndpointAuthMethod = oidc.AuthMethodClientSecretBasic
}
if crr.RawTokenEndpointAuthMethod != "" {
switch crr.RawTokenEndpointAuthMethod {
case oidc.AuthMethodClientSecretBasic:
// breaks
case oidc.AuthMethodNone:
// breaks
default:
return konnectoidc.NewOAuth2Error(oidc.ErrorCodeOIDCInvalidClientMetadata, "unsupported token_endpoint_auth_method")
}
}
if crr.RawTokenEndpointAuthSigningAlg != "" {
alg := jwt.GetSigningMethod(crr.RawTokenEndpointAuthSigningAlg)
if alg == nil {
return konnectoidc.NewOAuth2Error(oidc.ErrorCodeOIDCInvalidClientMetadata, "unknown token_endpoint_auth_signing_alg")
}
}
for _, uriString := range crr.PostLogoutRedirectURIs {
_, err := url.Parse(uriString)
if err != nil {
return konnectoidc.NewOAuth2Error(oidc.ErrorCodeOIDCInvalidClientMetadata, "failed to parse post_logout_redirect_uris")
}
}
if crr.JWKS != nil {
if len(crr.JWKS.Keys) == 0 {
crr.JWKS = nil
} else {
enc := false
empty := true
for _, key := range crr.JWKS.Keys {
switch key.Use {
case "":
if enc {
return konnectoidc.NewOAuth2Error(oidc.ErrorCodeOIDCInvalidClientMetadata, "jwks includes enc key and unset use key")
}
empty = true
key.Use = "sig"
case "enc":
enc = true
if empty {
return konnectoidc.NewOAuth2Error(oidc.ErrorCodeOIDCInvalidClientMetadata, "jwks includes enc key and unset use key")
}
}
}
}
}
return nil
}
// ClientRegistration returns new dynamic client registration data for the
// accociated client registration request.
func (crr *ClientRegistrationRequest) ClientRegistration() (*clients.ClientRegistration, error) {
cr := &clients.ClientRegistration{
Contacts: crr.Contacts,
Name: crr.ClientName,
URI: crr.ClientURI,
GrantTypes: crr.GrantTypes,
ApplicationType: crr.ApplicationType,
RedirectURIs: crr.RedirectURIs,
JWKS: crr.JWKS,
RawIDTokenSignedResponseAlg: crr.RawIDTokenSignedResponseAlg,
RawUserInfoSignedResponseAlg: crr.RawUserInfoSignedResponseAlg,
RawRequestObjectSigningAlg: crr.RawRequestObjectSigningAlg,
RawTokenEndpointAuthMethod: crr.RawTokenEndpointAuthMethod,
RawTokenEndpointAuthSigningAlg: crr.RawTokenEndpointAuthSigningAlg,
PostLogoutRedirectURIs: crr.PostLogoutRedirectURIs,
}
return cr, nil
}
// ClientRegistrationResponse holds the outgoing data for a successful OpenID
// Connect Dynamic Client Registration 1.0 clientregistration request as
// specified at https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationResponse
type ClientRegistrationResponse struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret,omitempty"`
ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"`
ClientSecretExpiresAt int64 `json:"client_secret_expires_at"`
// Include validated request data.
ClientRegistrationRequest
}
+69
View File
@@ -0,0 +1,69 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package payload
import (
"errors"
"github.com/golang-jwt/jwt/v5"
"github.com/libregraph/lico/identity/clients"
)
// RequestObjectClaims holds the incoming request object claims provided as
// JWT via request parameter to OpenID Connect 1.0 authorization endpoint
// requests specified at
// https://openid.net/specs/openid-connect-core-1_0.html#JWTRequests
type RequestObjectClaims struct {
jwt.RegisteredClaims
RawScope string `json:"scope"`
Claims *ClaimsRequest `json:"claims"`
RawResponseType string `json:"response_type"`
ResponseMode string `json:"response_mode"`
ClientID string `json:"client_id"`
RawRedirectURI string `json:"redirect_uri"`
State string `json:"state"`
Nonce string `json:"nonce"`
RawPrompt string `json:"prompt"`
RawIDTokenHint string `json:"id_token_hint"`
RawMaxAge string `json:"max_age"`
RawRegistration string `json:"registration"`
CodeChallenge string `json:"code_challenge"`
CodeChallengeMethod string `json:"code_challenge_method"`
client *clients.Secured
}
// SetSecure sets the provided client as owner of the accociated claims.
func (roc *RequestObjectClaims) SetSecure(client *clients.Secured) error {
if roc.ClientID != client.ID {
return errors.New("client ID mismatch")
}
roc.client = client
return nil
}
// Secure returns the accociated secure client or nil if not secure.
func (roc *RequestObjectClaims) Secure() *clients.Secured {
return roc.client
}
+55
View File
@@ -0,0 +1,55 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package payload
import (
"encoding/json"
"reflect"
"github.com/gorilla/schema"
)
var decoder = schema.NewDecoder()
var encoder = schema.NewEncoder()
// DecodeSchema decodes request form data into the provided dst schema struct.
func DecodeSchema(dst interface{}, src map[string][]string) error {
return decoder.Decode(dst, src)
}
// EncodeSchema encodes the provided src schema to the provided map.
func EncodeSchema(src interface{}, dst map[string][]string) error {
return encoder.Encode(src, dst)
}
// ConvertOIDCClaimsRequest is a converter function for oidc.ClaimsRequest data
// provided in URL schema.
func ConvertOIDCClaimsRequest(value string) reflect.Value {
v := ClaimsRequest{}
if err := json.Unmarshal([]byte(value), &v); err != nil {
return reflect.Value{}
}
return reflect.ValueOf(v)
}
func init() {
decoder.IgnoreUnknownKeys(true)
decoder.RegisterConverter(ClaimsRequest{}, ConvertOIDCClaimsRequest)
}
+30
View File
@@ -0,0 +1,30 @@
/*
* Copyright 2017-2019 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package payload
// Session defines a Provider's session with a String identifier for a Session.
// This represents a Session of a User Agent or device for a logged-in End-User
// at an RP. Different ID values are used to identify distinct sessions. This
// is implemented as defined in the OIDC Front Channel logout extension
// https://openid.net/specs/openid-connect-frontchannel-1_0.html#OPLogout
type Session struct {
Version int
ID string
Sub string
Provider string
}

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