Initial QSfera import
This commit is contained in:
+7
@@ -0,0 +1,7 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 2
|
||||
+32
@@ -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
@@ -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
|
||||
+1124
File diff suppressed because it is too large
Load Diff
+68
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
|
||||
LibreGraph Connect
|
||||
Copyright 2017-2021 Kopano and its licensors
|
||||
|
||||
This product includes software developed at
|
||||
Kopano (https://kopano.com/).
|
||||
+242
@@ -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.
|
||||
|
||||
[](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.
|
||||
+53
@@ -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
|
||||
}
|
||||
+175
@@ -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
|
||||
}
|
||||
Generated
Vendored
+158
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
+96
@@ -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
@@ -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
@@ -0,0 +1,7 @@
|
||||
compressionLevel: mixed
|
||||
|
||||
enableGlobalCache: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.0.2.cjs
|
||||
+65
@@ -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
@@ -0,0 +1,3 @@
|
||||
# LibreGraph Connect Identifier
|
||||
|
||||
Web app for browser sign-in, sign-out and account management.
|
||||
+146
@@ -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
|
||||
}
|
||||
+54
@@ -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
|
||||
}
|
||||
+41
@@ -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"
|
||||
)
|
||||
+670
@@ -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))
|
||||
}
|
||||
Generated
Vendored
+660
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"`
|
||||
}
|
||||
+25
@@ -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"`
|
||||
}
|
||||
+161
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": [
|
||||
"src/vite-env.d.ts",
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
+251
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
+139
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
+225
@@ -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
@@ -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
|
||||
}
|
||||
+108
@@ -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
|
||||
}
|
||||
Generated
Vendored
+125
@@ -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)
|
||||
}
|
||||
Generated
Vendored
+126
@@ -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)
|
||||
}
|
||||
Generated
Vendored
+57
@@ -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
|
||||
}
|
||||
Generated
Vendored
+88
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
+113
@@ -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
@@ -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
|
||||
}
|
||||
+562
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
+113
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user