From af021d1f8162dc525035d7f9a6ceffa60d334064 Mon Sep 17 00:00:00 2001 From: Mike Mason Date: Tue, 30 Apr 2024 07:39:09 -0500 Subject: [PATCH] initial commit --- .github/CODEOWNERS | 1 + .github/workflows/test.yaml | 33 ++ .gitignore | 2 + .golangci.yaml | 68 +++ LICENSE | 201 ++++++++ Makefile | 29 ++ README.md | 5 + go.mod | 37 ++ go.sum | 70 +++ iamruntime/authentication.go | 31 ++ iamruntime/authentication_test.go | 97 ++++ iamruntime/authorization.go | 75 +++ iamruntime/authorization_test.go | 340 ++++++++++++++ iamruntime/context.go | 111 +++++ iamruntime/doc.go | 2 + iamruntime/errors.go | 50 ++ iamruntime/runtime.go | 36 ++ iamruntime/runtime_test.go | 19 + internal/context.go | 18 + internal/doc.go | 2 + internal/headers.go | 32 ++ internal/testauth/options.go | 34 ++ internal/testauth/testauth.go | 185 ++++++++ .../iamruntimemiddleware/authentication.go | 61 +++ .../authentication_test.go | 115 +++++ .../iamruntimemiddleware/authorization.go | 71 +++ .../authorization_test.go | 431 ++++++++++++++++++ .../echo/iamruntimemiddleware/config.go | 59 +++ .../echo/iamruntimemiddleware/context.go | 36 ++ middleware/echo/iamruntimemiddleware/doc.go | 2 + .../echo/iamruntimemiddleware/middleware.go | 55 +++ .../iamruntimemiddleware/middleware_test.go | 117 +++++ mockruntime/authentication.go | 26 ++ mockruntime/authorization.go | 61 +++ mockruntime/identity.go | 15 + mockruntime/runtime.go | 18 + oauth2/filetokensource/doc.go | 4 + oauth2/filetokensource/handler.go | 92 ++++ oauth2/filetokensource/handler_test.go | 20 + oauth2/filetokensource/options.go | 75 +++ oauth2/filetokensource/token.go | 124 +++++ oauth2/filetokensource/token_test.go | 159 +++++++ oauth2/filetokensource/watch.go | 44 ++ oauth2/filetokensource/watch_test.go | 148 ++++++ oauth2/iamruntimetokensource/tokensource.go | 72 +++ .../iamruntimetokensource/tokensource_test.go | 99 ++++ renovate.json | 15 + 47 files changed, 3397 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/test.yaml create mode 100644 .gitignore create mode 100644 .golangci.yaml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 iamruntime/authentication.go create mode 100644 iamruntime/authentication_test.go create mode 100644 iamruntime/authorization.go create mode 100644 iamruntime/authorization_test.go create mode 100644 iamruntime/context.go create mode 100644 iamruntime/doc.go create mode 100644 iamruntime/errors.go create mode 100644 iamruntime/runtime.go create mode 100644 iamruntime/runtime_test.go create mode 100644 internal/context.go create mode 100644 internal/doc.go create mode 100644 internal/headers.go create mode 100644 internal/testauth/options.go create mode 100644 internal/testauth/testauth.go create mode 100644 middleware/echo/iamruntimemiddleware/authentication.go create mode 100644 middleware/echo/iamruntimemiddleware/authentication_test.go create mode 100644 middleware/echo/iamruntimemiddleware/authorization.go create mode 100644 middleware/echo/iamruntimemiddleware/authorization_test.go create mode 100644 middleware/echo/iamruntimemiddleware/config.go create mode 100644 middleware/echo/iamruntimemiddleware/context.go create mode 100644 middleware/echo/iamruntimemiddleware/doc.go create mode 100644 middleware/echo/iamruntimemiddleware/middleware.go create mode 100644 middleware/echo/iamruntimemiddleware/middleware_test.go create mode 100644 mockruntime/authentication.go create mode 100644 mockruntime/authorization.go create mode 100644 mockruntime/identity.go create mode 100644 mockruntime/runtime.go create mode 100644 oauth2/filetokensource/doc.go create mode 100644 oauth2/filetokensource/handler.go create mode 100644 oauth2/filetokensource/handler_test.go create mode 100644 oauth2/filetokensource/options.go create mode 100644 oauth2/filetokensource/token.go create mode 100644 oauth2/filetokensource/token_test.go create mode 100644 oauth2/filetokensource/watch.go create mode 100644 oauth2/filetokensource/watch_test.go create mode 100644 oauth2/iamruntimetokensource/tokensource.go create mode 100644 oauth2/iamruntimetokensource/tokensource_test.go create mode 100644 renovate.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..3a66e64 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @metal-toolbox/identity-core diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..9b7296b --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,33 @@ +--- +name: Lint and test + +# Run for all pushes to main and pull requests +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +jobs: + lint-and-test: + runs-on: "ubuntu-latest" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run go tests and generate coverage report + run: make test + + # - name: Upload coverage report + # uses: codecov/codecov-action@v4 + # with: + # token: ${{ secrets.CODECOV_TOKEN }} + # file: ./coverage.out + # flags: unittests + # name: codecov-umbrella diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b60598 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.tools/ +coverage.out diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..c9cca4e --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,68 @@ +run: + build-tags: + - testtools + +linters-settings: + goimports: + local-prefixes: github.com/metal-toolbox/iam-runtime-contrib + gofumpt: + extra-rules: true + +linters: + enable: + # default linters + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - typecheck + - unused + + # additional linters + - bodyclose + - gocritic + - gocyclo + - goerr113 + - gofmt + # - gofumpt + - goimports + - gomnd + - govet + - misspell + - noctx + - revive + - stylecheck + - whitespace + - wsl + + # - bod +issues: + exclude-rules: + - path: (.+)_test.go + linters: + - wsl + text: "block should not start with a whitespace" + exclude: + # Default excludes from `golangci-lint run --help` with EXC0002 removed + # EXC0001 errcheck: Almost all programs ignore errors on these functions and in most cases it's ok + - Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*print(f|ln)?|os\.(Un)?Setenv). is not checked + # EXC0002 golint: Annoying issue about not having a comment. The rare codebase has such comments + # - (comment on exported (method|function|type|const)|should have( a package)? comment|comment should be of the form) + # EXC0003 golint: False positive when tests are defined in package 'test' + - func name will be used as test\.Test.* by other packages, and that stutters; consider calling this + # EXC0004 govet: Common false positives + - (possible misuse of unsafe.Pointer|should have signature) + # EXC0005 staticcheck: Developers tend to write in C-style with an explicit 'break' in a 'switch', so it's ok to ignore + - ineffective break statement. Did you mean to break out of the outer loop + # EXC0006 gosec: Too many false-positives on 'unsafe' usage + - Use of unsafe calls should be audited + # EXC0007 gosec: Too many false-positives for parametrized shell calls + - Subprocess launch(ed with variable|ing should be audited) + # EXC0008 gosec: Duplicated errcheck checks + - (G104|G307) + # EXC0009 gosec: Too many issues in popular repos + - (Expect directory permissions to be 0750 or less|Expect file permissions to be 0600 or less) + # EXC0010 gosec: False positive is triggered by 'src, err := ioutil.ReadFile(filename)' + - Potential file inclusion via variable + exclude-use-default: false diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ce1a5b4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + 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 2024 Equinix + + 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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..521c97d --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) +TOOLS_DIR := .tools +GOOS ?= linux +GOARCH ?= amd64 + +GOLANGCI_LINT_REPO = github.com/golangci/golangci-lint +GOLANGCI_LINT_VERSION = v1.57.2 + +all: test +PHONY: test lint + +test: | lint + @echo Running tests... + @go test -mod=readonly -race -coverprofile=coverage.out -covermode=atomic ./... + +lint: $(TOOLS_DIR)/golangci-lint + @echo Linting Go files... + @$(TOOLS_DIR)/golangci-lint run --modules-download-mode=readonly + +go-dependencies: + @go mod download + @go mod tidy + +$(TOOLS_DIR): + mkdir -p $(TOOLS_DIR) + +$(TOOLS_DIR)/golangci-lint: | $(TOOLS_DIR) + @echo "Installing $(GOLANGCI_LINT_REPO)/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)" + @GOBIN=$(ROOT_DIR)/$(TOOLS_DIR) go install $(GOLANGCI_LINT_REPO)/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) diff --git a/README.md b/README.md new file mode 100644 index 0000000..c384872 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# IAM Runtime Contribution + +[![GoDoc](https://img.shields.io/badge/go-documentation-blue.svg?style=flat-square)](https://pkg.go.dev/github.com/metal-toolbox/iam-runtime-contrib) + +This repository contains additional packages which assist in using iam-runtime implementations. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..56b0cf0 --- /dev/null +++ b/go.mod @@ -0,0 +1,37 @@ +module github.com/metal-toolbox/iam-runtime-contrib + +go 1.22.1 + +require ( + github.com/go-jose/go-jose/v4 v4.0.1 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/labstack/echo/v4 v4.12.0 + github.com/metal-toolbox/iam-runtime v0.4.1 + github.com/stretchr/testify v1.9.0 + go.uber.org/zap v1.27.0 + golang.org/x/oauth2 v0.19.0 + google.golang.org/grpc v1.63.2 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/crypto v0.22.0 // indirect + golang.org/x/net v0.24.0 // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.5.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c87bd48 --- /dev/null +++ b/go.sum @@ -0,0 +1,70 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U= +github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= +github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/metal-toolbox/iam-runtime v0.4.1 h1:xeUSB9gnc2e4MYhoWXAwBHGJDQFlPEFDcid5PEzR7lA= +github.com/metal-toolbox/iam-runtime v0.4.1/go.mod h1:tZZ1qJy1Rc/onvsX9TRdEu5IYCa9H5WnFlM1EviFqP8= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/oauth2 v0.19.0 h1:9+E/EZBCbTLNrbN35fHv/a/d/mOBatymz1zbtQrXpIg= +golang.org/x/oauth2 v0.19.0/go.mod h1:vYi7skDa1x015PmRRYZ7+s1cWyPgrPiSYRe4rnsexc8= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de h1:cZGRis4/ot9uVm639a+rHCUaG0JJHEsdyzSQTMX+suY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:H4O17MA/PE9BsGx3w+a+W2VOLLD1Qf7oJneAoU6WktY= +google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= +google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/iamruntime/authentication.go b/iamruntime/authentication.go new file mode 100644 index 0000000..10afaca --- /dev/null +++ b/iamruntime/authentication.go @@ -0,0 +1,31 @@ +package iamruntime + +import ( + "context" + "fmt" + + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authentication" + "google.golang.org/grpc" +) + +// ContextValidateCredential executes a credential validation request on the runtime in the context. +// Context must have a runtime value. +// The runtime must implement the iam-runtime's AuthenticationClient. +// Use [SetContextRuntime] to set this value. +func ContextValidateCredential(ctx context.Context, in *authentication.ValidateCredentialRequest, opts ...grpc.CallOption) error { + runtime := ContextRuntimeAuthenticationClient(ctx) + if runtime == nil { + return ErrRuntimeNotFound + } + + resp, err := runtime.ValidateCredential(ctx, in, opts...) + if err != nil { + return fmt.Errorf("%w: %w", ErrCredentialValidationRequestFailed, err) + } + + if resp.Result == authentication.ValidateCredentialResponse_RESULT_INVALID { + return ErrInvalidCredentials + } + + return nil +} diff --git a/iamruntime/authentication_test.go b/iamruntime/authentication_test.go new file mode 100644 index 0000000..cd49a23 --- /dev/null +++ b/iamruntime/authentication_test.go @@ -0,0 +1,97 @@ +package iamruntime + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/labstack/echo/v4" + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authentication" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + "github.com/metal-toolbox/iam-runtime-contrib/internal/testauth" + "github.com/metal-toolbox/iam-runtime-contrib/mockruntime" +) + +func TestValidateCredential(t *testing.T) { + authsrv := testauth.NewServer(t) + t.Cleanup(authsrv.Stop) + + testCases := []struct { + name string + authenticationResponse *authentication.ValidateCredentialResponse + authenticationError error + expectError error + }{ + { + "permitted", + &authentication.ValidateCredentialResponse{Result: authentication.ValidateCredentialResponse_RESULT_VALID}, + nil, + nil, + }, + { + "denied", + &authentication.ValidateCredentialResponse{Result: authentication.ValidateCredentialResponse_RESULT_INVALID}, + nil, + ErrInvalidCredentials, + }, + { + "failed request", + nil, + grpc.ErrServerStopped, + ErrCredentialValidationRequestFailed, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + runtime := new(mockruntime.MockRuntime) + + runtime.Mock.On("ValidateCredential", "some subject").Return(tc.authenticationResponse, tc.authenticationError) + + engine := echo.New() + + engine.Debug = true + + ctx := context.Background() + + ctx = SetContextRuntime(ctx, runtime) + + err := ContextValidateCredential(ctx, &authentication.ValidateCredentialRequest{ + Credential: authsrv.TSignSubject(t, "some subject"), + }) + + if tc.expectError != nil { + require.Error(t, err, "expected error to be returned") + assert.ErrorIs(t, err, tc.expectError, "unexpected error returned") + } else { + assert.NoError(t, err, "expected no error to be returned") + } + + runtime.Mock.AssertExpectations(t) + }) + } +} + +func ExampleContextValidateCredential() { + runtime, _ := NewClient("/tmp/runtime.sock") + + ctx := SetContextRuntime(context.TODO(), runtime) + + someToken := "some token" + + if err := ContextValidateCredential(ctx, &authentication.ValidateCredentialRequest{Credential: someToken}); err != nil { + if errors.Is(err, ErrInvalidCredentials) { + fmt.Println("other credentials are invalid", err) + + return + } + + fmt.Println("failed to validate credentials", err) + } + + fmt.Println("Credentials are valid!") +} diff --git a/iamruntime/authorization.go b/iamruntime/authorization.go new file mode 100644 index 0000000..3c84b6c --- /dev/null +++ b/iamruntime/authorization.go @@ -0,0 +1,75 @@ +package iamruntime + +import ( + "context" + "fmt" + + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authorization" + "google.golang.org/grpc" +) + +// ContextCheckAccess executes an access request on the runtime in the context. +// Context must have a token and runtime value. +// The runtime must implement the iam-runtime's AuthorizationClient. +// Use [SetContextToken] and [SetContextRuntime] to set these values. +func ContextCheckAccess(ctx context.Context, actions []*authorization.AccessRequestAction, opts ...grpc.CallOption) error { + token := ContextToken(ctx) + if token == nil { + return ErrTokenNotFound + } + + runtime := ContextRuntimeAuthorizationClient(ctx) + if runtime == nil { + return ErrRuntimeNotFound + } + + resp, err := runtime.CheckAccess(ctx, &authorization.CheckAccessRequest{ + Credential: token.Raw, + Actions: actions, + }, opts...) + if err != nil { + return fmt.Errorf("%w: %w", ErrAccessCheckFailed, err) + } + + if resp.Result == authorization.CheckAccessResponse_RESULT_DENIED { + return ErrAccessDenied + } + + return nil +} + +// ContextCreateRelationships executes a create relationship request on the runtime in the context. +// Context must have a runtime value. +// The runtime must implement the iam-runtime's AuthorizationClient. +// Use [SetContextRuntime] to set this value. +func ContextCreateRelationships(ctx context.Context, in *authorization.CreateRelationshipsRequest, opts ...grpc.CallOption) (*authorization.CreateRelationshipsResponse, error) { + runtime := ContextRuntimeAuthorizationClient(ctx) + if runtime == nil { + return nil, ErrRuntimeNotFound + } + + resp, err := runtime.CreateRelationships(ctx, in, opts...) + if err != nil { + return nil, fmt.Errorf("%w: create: %w", ErrRelationshipRequestFailed, err) + } + + return resp, nil +} + +// ContextDeleteRelationships executes a delete relationship request on the runtime in the context. +// Context must have a runtime value. +// The runtime must implement the iam-runtime's AuthorizationClient. +// Use [SetContextRuntime] to set this value. +func ContextDeleteRelationships(ctx context.Context, in *authorization.DeleteRelationshipsRequest, opts ...grpc.CallOption) (*authorization.DeleteRelationshipsResponse, error) { + runtime := ContextRuntimeAuthorizationClient(ctx) + if runtime == nil { + return nil, ErrRuntimeNotFound + } + + resp, err := runtime.DeleteRelationships(ctx, in, opts...) + if err != nil { + return nil, fmt.Errorf("%w: delete: %w", ErrRelationshipRequestFailed, err) + } + + return resp, nil +} diff --git a/iamruntime/authorization_test.go b/iamruntime/authorization_test.go new file mode 100644 index 0000000..296a9d1 --- /dev/null +++ b/iamruntime/authorization_test.go @@ -0,0 +1,340 @@ +package iamruntime + +import ( + "context" + "fmt" + "testing" + + "github.com/golang-jwt/jwt/v5" + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authorization" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + "github.com/metal-toolbox/iam-runtime-contrib/internal/testauth" + "github.com/metal-toolbox/iam-runtime-contrib/mockruntime" +) + +func TestContextCheckAccess(t *testing.T) { + authsrv := testauth.NewServer(t) + t.Cleanup(authsrv.Stop) + + testCases := []struct { + name string + actions []*authorization.AccessRequestAction + returnAccessResult authorization.CheckAccessResponse_Result + returnAccessError error + expectCalled map[string][]string + expectError error + }{ + { + "permitted", + []*authorization.AccessRequestAction{ + { + ResourceId: "testten-abc123", + Action: "action_one", + }, + { + ResourceId: "testten-abc123", + Action: "action_two", + }, + { + ResourceId: "testten-def456", + Action: "action_one", + }, + }, + authorization.CheckAccessResponse_RESULT_ALLOWED, + nil, + map[string][]string{ + "testten-abc123": {"action_one", "action_two"}, + "testten-def456": {"action_one"}, + }, + nil, + }, + { + "denied", + []*authorization.AccessRequestAction{ + { + ResourceId: "testten-abc123", + Action: "action_one", + }, + }, + authorization.CheckAccessResponse_RESULT_DENIED, + nil, + map[string][]string{"testten-abc123": {"action_one"}}, + ErrAccessDenied, + }, + { + "error", + []*authorization.AccessRequestAction{ + { + ResourceId: "testten-abc123", + Action: "action_one", + }, + }, + 0, + grpc.ErrServerStopped, + map[string][]string{"testten-abc123": {"action_one"}}, + ErrAccessCheckFailed, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + runtime := new(mockruntime.MockRuntime) + + runtime.Mock.On("CheckAccess", tc.expectCalled).Return(tc.returnAccessResult, tc.returnAccessError) + + token, _, err := jwt.NewParser().ParseUnverified(authsrv.TSignSubject(t, "some subject"), jwt.MapClaims{}) + require.NoError(t, err, "unexpected error creating jwt") + + ctx := context.Background() + + ctx = SetContextRuntime(ctx, runtime) + ctx = SetContextToken(ctx, token) + + err = ContextCheckAccess(ctx, tc.actions) + + if tc.expectError != nil { + require.Error(t, err, "expected error to be returned") + assert.ErrorIs(t, err, tc.expectError, "unexpected error returned") + } else { + assert.NoError(t, err, "expected no error to be returned") + } + + runtime.Mock.AssertExpectations(t) + }) + } +} + +func TestContextCreateRelationships(t *testing.T) { + testCases := []struct { + name string + request *authorization.CreateRelationshipsRequest + requestError error + expectCalled map[string][]string + expectError error + }{ + { + "created", + &authorization.CreateRelationshipsRequest{ + ResourceId: "testten-abc123", + Relationships: []*authorization.Relationship{ + { + Relation: "parent", + SubjectId: "testten-root123", + }, + }, + }, + nil, + map[string][]string{ + "parent": {"testten-root123"}, + }, + nil, + }, + { + "failed", + &authorization.CreateRelationshipsRequest{ + ResourceId: "testten-abc123", + Relationships: []*authorization.Relationship{ + { + Relation: "parent", + SubjectId: "testten-root123", + }, + }, + }, + grpc.ErrServerStopped, + map[string][]string{ + "parent": {"testten-root123"}, + }, + ErrRelationshipRequestFailed, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + runtime := new(mockruntime.MockRuntime) + + runtime.Mock.On("CreateRelationships", tc.request.ResourceId, tc.expectCalled).Return(tc.requestError) + + ctx := context.Background() + + ctx = SetContextRuntime(ctx, runtime) + + _, err := ContextCreateRelationships(ctx, tc.request) + + if tc.expectError != nil { + require.Error(t, err, "expected error to be returned") + assert.ErrorIs(t, err, tc.expectError, "unexpected error returned") + } else { + assert.NoError(t, err, "expected no error to be returned") + } + + runtime.Mock.AssertExpectations(t) + }) + } +} + +func TestContextDeleteRelationships(t *testing.T) { + testCases := []struct { + name string + request *authorization.DeleteRelationshipsRequest + requestError error + expectCalled map[string][]string + expectError error + }{ + { + "deleted", + &authorization.DeleteRelationshipsRequest{ + ResourceId: "testten-abc123", + Relationships: []*authorization.Relationship{ + { + Relation: "parent", + SubjectId: "testten-root123", + }, + }, + }, + nil, + map[string][]string{ + "parent": {"testten-root123"}, + }, + nil, + }, + { + "failed", + &authorization.DeleteRelationshipsRequest{ + ResourceId: "testten-abc123", + Relationships: []*authorization.Relationship{ + { + Relation: "parent", + SubjectId: "testten-root123", + }, + }, + }, + grpc.ErrServerStopped, + map[string][]string{ + "parent": {"testten-root123"}, + }, + ErrRelationshipRequestFailed, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + runtime := new(mockruntime.MockRuntime) + + runtime.Mock.On("DeleteRelationships", tc.request.ResourceId, tc.expectCalled).Return(tc.requestError) + + ctx := context.Background() + + ctx = SetContextRuntime(ctx, runtime) + + _, err := ContextDeleteRelationships(ctx, tc.request) + + if tc.expectError != nil { + require.Error(t, err, "expected error to be returned") + assert.ErrorIs(t, err, tc.expectError, "unexpected error returned") + } else { + assert.NoError(t, err, "expected no error to be returned") + } + + runtime.Mock.AssertExpectations(t) + }) + } +} + +func ExampleContextCheckAccess() { + runtime, _ := NewClient("/tmp/runtime.sock") + + ctx := SetContextRuntime(context.TODO(), runtime) + ctx = SetContextToken(ctx, &jwt.Token{Raw: "some token"}) + + check := []*authorization.AccessRequestAction{ + {ResourceId: "resctyp-abc123", Action: "resource_get"}, + } + + if err := ContextCheckAccess(ctx, check); err != nil { + panic("failed to check access: " + err.Error()) + } + + fmt.Println("Token has access to resource!") +} + +// StorageResource is used in examples. +type StorageResource struct { + ID string + ParentResourceID string +} + +// GetResource is used in examples. +func GetResource() StorageResource { + return StorageResource{ + ID: "testten-abc123", + ParentResourceID: "testten-root123", + } +} + +// CreateResource is used in examples. +func CreateResource() StorageResource { + return StorageResource{ + ID: "testten-abc123", + ParentResourceID: "testten-root123", + } +} + +// DeleteResource is used in examples. +func DeleteResource() error { + return nil +} + +func ExampleContextCreateRelationships() { + runtime, _ := NewClient("/tmp/runtime.sock") + + ctx := SetContextRuntime(context.TODO(), runtime) + + resource := CreateResource() + + relationRequest := &authorization.CreateRelationshipsRequest{ + ResourceId: resource.ID, + Relationships: []*authorization.Relationship{ + { + Relation: "parent", + SubjectId: resource.ParentResourceID, + }, + }, + } + + if _, err := ContextCreateRelationships(ctx, relationRequest); err != nil { + panic("failed to create relationships: " + err.Error()) + } + + fmt.Println("Relationships created!") +} + +func ExampleContextDeleteRelationships() { + runtime, _ := NewClient("/tmp/runtime.sock") + + ctx := SetContextRuntime(context.TODO(), runtime) + + resource := GetResource() + + if err := DeleteResource(); err != nil { + panic("failed to delete resource: " + err.Error()) + } + + relationRequest := &authorization.DeleteRelationshipsRequest{ + ResourceId: resource.ID, + Relationships: []*authorization.Relationship{ + { + Relation: "parent", + SubjectId: resource.ParentResourceID, + }, + }, + } + + if _, err := ContextDeleteRelationships(ctx, relationRequest); err != nil { + panic("failed to delete relationships: " + err.Error()) + } + + fmt.Println("Relationships deleted!") +} diff --git a/iamruntime/context.go b/iamruntime/context.go new file mode 100644 index 0000000..eae7352 --- /dev/null +++ b/iamruntime/context.go @@ -0,0 +1,111 @@ +package iamruntime + +import ( + "context" + + "github.com/golang-jwt/jwt/v5" + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authentication" + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authorization" + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/identity" + + "github.com/metal-toolbox/iam-runtime-contrib/internal" +) + +// ContextRuntime retrieves the iam-runtime from the context and ensures it implements all clients. +// If the runtime is not found in the provided context, nil is returned. +// If the stored runtime does not implement all clients, nil is returned. Instead use [GetRuntimeAny]. +func ContextRuntime(ctx context.Context) Runtime { + if runtime, ok := ctx.Value(internal.RuntimeCtxKey).(Runtime); ok { + return runtime + } + + return nil +} + +// ContextRuntimeAny retrieves the iam-runtime from the context and returns an interface. +// If the runtime is not found in the provided context, nil is returned. +// If the stored runtime implements all client implementations, use [GetRuntime]. +func ContextRuntimeAny(ctx context.Context) any { + return ctx.Value(internal.RuntimeCtxKey) +} + +// ContextRuntimeAuthenticationClient retrieves the iam runtime from the context and ensures it has the authorization client interface. +// If the runtime is not found in the provided context or it doesn't implement the authorization client, nil is returned. +func ContextRuntimeAuthenticationClient(ctx context.Context) authentication.AuthenticationClient { + if runtime, ok := ctx.Value(internal.RuntimeCtxKey).(authentication.AuthenticationClient); ok { + return runtime + } + + return nil +} + +// ContextRuntimeAuthorizationClient retrieves the iam runtime from the context and ensures it has the authorization client interface. +// If the runtime is not found in the provided context or it doesn't implement the authorization client, nil is returned. +func ContextRuntimeAuthorizationClient(ctx context.Context) authorization.AuthorizationClient { + if runtime, ok := ctx.Value(internal.RuntimeCtxKey).(authorization.AuthorizationClient); ok { + return runtime + } + + return nil +} + +// ContextRuntimeIdentityClient retrieves the iam runtime from the context and ensures it has the identity client interface. +// If the runtime is not found in the provided context or it doesn't implement the identity client, nil is returned. +func ContextRuntimeIdentityClient(ctx context.Context) identity.IdentityClient { + if runtime, ok := ctx.Value(internal.RuntimeCtxKey).(identity.IdentityClient); ok { + return runtime + } + + return nil +} + +// ContextToken retrieves the decoded jwt token from the provided context. +// If the token is not found in the provided context, nil is returned. +func ContextToken(ctx context.Context) *jwt.Token { + if token, ok := ctx.Value(internal.TokenCtxKey).(*jwt.Token); ok { + return token + } + + return nil +} + +// ContextSubject retrieves the subject from the provided context. +// If the subject is not found in the provided context, an empty string is returned. +func ContextSubject(ctx context.Context) string { + if subject, ok := ctx.Value(internal.SubjectCtxKey).(string); ok { + return subject + } + + return "" +} + +// SetContextRuntime sets the runtime context key to the provided runtime. +// The provided runtime must implement all iam-runtime clients. +// +// If only a limited number of clients are required, use [SetContextRuntimeAny]. +func SetContextRuntime(ctx context.Context, value Runtime) context.Context { + return context.WithValue(ctx, internal.RuntimeCtxKey, value) +} + +// SetContextRuntimeAny sets the runtime context key to the provided runtime. +// No validation is done that the value provided is actually an iam-runtime client +// implementation as different applications may only require a subset of client +// implementations. +// +// Ensure the value provided meets all your requirements before setting the value, +// otherwise context functions will fail. +// +// If the runtime implements all iam-runtime client's, use [SetContextRuntime]. +func SetContextRuntimeAny(ctx context.Context, value any) context.Context { + return context.WithValue(ctx, internal.RuntimeCtxKey, value) +} + +// SetContextToken sets the token context key to the provided token. +func SetContextToken(ctx context.Context, value *jwt.Token) context.Context { + return context.WithValue(ctx, internal.TokenCtxKey, value) +} + +// SetContextSubject sets the subject context key to the provided value. +func SetContextSubject(ctx context.Context, value string) context.Context { + return context.WithValue(ctx, internal.SubjectCtxKey, value) +} diff --git a/iamruntime/doc.go b/iamruntime/doc.go new file mode 100644 index 0000000..e4fb5be --- /dev/null +++ b/iamruntime/doc.go @@ -0,0 +1,2 @@ +// Package iamruntime contains useful functions for working with the iam-runtime and this contrib library. +package iamruntime diff --git a/iamruntime/errors.go b/iamruntime/errors.go new file mode 100644 index 0000000..667936d --- /dev/null +++ b/iamruntime/errors.go @@ -0,0 +1,50 @@ +package iamruntime + +import ( + "errors" + "fmt" +) + +var ( + // Error is the root error for all iam-runtime related errors. + Error = errors.New("iam-runtime error") + + // ErrRuntimeNotFound is the error returned when the runtime is not found in the context. + ErrRuntimeNotFound = fmt.Errorf("%w: runtime not found", Error) + + // AuthError is the root error all auth related errors stem from. + AuthError = fmt.Errorf("%w: auth", Error) //nolint:revive,stylecheck // not returned directly, but used as a root error. + + // ErrCredentialValidationRequestFailed is the error returned when the credential validation request failed to execute. + ErrCredentialValidationRequestFailed = fmt.Errorf("%w: failed to execute validation request", AuthError) + + // ErrInvalidCredentials is the error returned when the provided credentials are not valid. + ErrInvalidCredentials = fmt.Errorf("%w: invalid credentials", AuthError) + + // ErrTokenNotFound is the error returned when the token is not found in the context. + ErrTokenNotFound = fmt.Errorf("%w: token not found", AuthError) + + // AccessError is the root error for all access related errors. + AccessError = fmt.Errorf("%w: access", Error) //nolint:revive,stylecheck // not returned directly, but used as a root error. + + // ErrAccessCheckFailed is the error returned when an access request failed to execute. + ErrAccessCheckFailed = fmt.Errorf("%w: failed to check access", AccessError) + + // ErrAccessDenied is the error returned when an access request is denied. + ErrAccessDenied = fmt.Errorf("%w: denied", AccessError) + + // RelationshipError is the root error for all relationship related errors. + RelationshipError = fmt.Errorf("%w: relationship", Error) //nolint:revive,stylecheck // not returned directly, but used as a root error. + + // ErrRelationshipRequestFailed is the error returned when a relationship request failed to execute. + ErrRelationshipRequestFailed = fmt.Errorf("%w: failed to execute relationship request", RelationshipError) + + // IdentityError is the root error for all identity related errors. + IdentityError = fmt.Errorf("%w: identity", Error) //nolint:revive,stylecheck // not returned directly, but used as a root error. + + // ErrIdentityTokenRequestFailed is the error returned when an access token request failed to execute. + ErrIdentityTokenRequestFailed = fmt.Errorf("%w: failed to request access token", IdentityError) + + // ErrAccessTokenInvalid is the error returned when an access token returned is not valid. + ErrAccessTokenInvalid = fmt.Errorf("%w: invalid access token", IdentityError) +) diff --git a/iamruntime/runtime.go b/iamruntime/runtime.go new file mode 100644 index 0000000..dbce422 --- /dev/null +++ b/iamruntime/runtime.go @@ -0,0 +1,36 @@ +package iamruntime + +import ( + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authentication" + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authorization" + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/identity" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// Runtime implements all iam-runtime clients. +type Runtime interface { + authorization.AuthorizationClient + authentication.AuthenticationClient + identity.IdentityClient +} + +type runtime struct { + authorization.AuthorizationClient + authentication.AuthenticationClient + identity.IdentityClient +} + +// NewClient creates a new iam-runtime which implements all clients. +func NewClient(socket string) (Runtime, error) { + conn, err := grpc.Dial(socket, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, err + } + + return &runtime{ + AuthorizationClient: authorization.NewAuthorizationClient(conn), + AuthenticationClient: authentication.NewAuthenticationClient(conn), + IdentityClient: identity.NewIdentityClient(conn), + }, nil +} diff --git a/iamruntime/runtime_test.go b/iamruntime/runtime_test.go new file mode 100644 index 0000000..0d03067 --- /dev/null +++ b/iamruntime/runtime_test.go @@ -0,0 +1,19 @@ +package iamruntime + +import ( + "context" + "fmt" + + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authentication" +) + +func ExampleNewClient() { + runtime, _ := NewClient("/path/to/socket") + + resp, _ := runtime.ValidateCredential(context.TODO(), &authentication.ValidateCredentialRequest{ + Credential: "some credential", + }) + + fmt.Println("Result:", resp.Result.String()) + fmt.Println("Subject:", resp.Subject.SubjectId) +} diff --git a/internal/context.go b/internal/context.go new file mode 100644 index 0000000..76bb040 --- /dev/null +++ b/internal/context.go @@ -0,0 +1,18 @@ +package internal + +type ( + runtimeCtxKey struct{} + tokenCtxKey struct{} + subjectCtxKey struct{} +) + +var ( + // RuntimeCtxKey is the context key used to retrieve the iam-runtime from the context. + RuntimeCtxKey = runtimeCtxKey{} + + // TokenCtxKey is the context key used to retrieve the decoded jwt token from a context. + TokenCtxKey = tokenCtxKey{} + + // SubjectCtxKey is the context key used to retrieve just the subject from a context. + SubjectCtxKey = subjectCtxKey{} +) diff --git a/internal/doc.go b/internal/doc.go new file mode 100644 index 0000000..9238044 --- /dev/null +++ b/internal/doc.go @@ -0,0 +1,2 @@ +// Package internal provides internal functions that are used by other packages. +package internal diff --git a/internal/headers.go b/internal/headers.go new file mode 100644 index 0000000..5bf8eec --- /dev/null +++ b/internal/headers.go @@ -0,0 +1,32 @@ +package internal + +import ( + "errors" + "net/http" + "strings" +) + +// ErrInvalidAuthToken is the error returned when the auth token is not the expected value. +var ErrInvalidAuthToken = errors.New("invalid auth token") + +const ( + authHeader = "Authorization" + bearerPrefix = "Bearer " +) + +// GetBearerToken parses the Authorization header returning just the Bearer token without the Bearer prefix. +func GetBearerToken(req *http.Request) (string, error) { + authHeader := strings.TrimSpace(req.Header.Get(authHeader)) + + if len(authHeader) <= len(bearerPrefix) { + return "", ErrInvalidAuthToken + } + + if !strings.EqualFold(authHeader[:len(bearerPrefix)], bearerPrefix) { + return "", ErrInvalidAuthToken + } + + token := authHeader[len(bearerPrefix):] + + return token, nil +} diff --git a/internal/testauth/options.go b/internal/testauth/options.go new file mode 100644 index 0000000..d240e73 --- /dev/null +++ b/internal/testauth/options.go @@ -0,0 +1,34 @@ +package testauth + +import "github.com/go-jose/go-jose/v4/jwt" + +// ClaimOption is a claim option definition. +type ClaimOption func(*jwt.Claims) + +// Subject lets you specify a subject claim option. +func Subject(v string) ClaimOption { + return func(c *jwt.Claims) { + c.Subject = v + } +} + +// Audience lets you specify an audience claim option. +func Audience(v ...string) ClaimOption { + return func(c *jwt.Claims) { + c.Audience = jwt.Audience(v) + } +} + +// Expiry lets you specify an expiry claim option. +func Expiry(v *jwt.NumericDate) ClaimOption { + return func(c *jwt.Claims) { + c.Expiry = v + } +} + +// NotBefore lets you specify a not before claim option. +func NotBefore(v *jwt.NumericDate) ClaimOption { + return func(c *jwt.Claims) { + c.NotBefore = v + } +} diff --git a/internal/testauth/testauth.go b/internal/testauth/testauth.go new file mode 100644 index 0000000..188f242 --- /dev/null +++ b/internal/testauth/testauth.go @@ -0,0 +1,185 @@ +// Package testauth implements a simple JWKS file server and token signer +// for use in test packages when jwt validation is required. +package testauth + +import ( + "crypto/rand" + "crypto/rsa" + "errors" + "fmt" + "net" + "net/http" + "sync" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/require" +) + +const ( + keySize = 2048 +) + +// Server handles serving JSON Web Key Set and signing tokens. +type Server struct { + t *testing.T + + started sync.Once + hasStarted bool + + cleanup []func() + stopped sync.Once + + engine *echo.Echo + + kid string + privKey *rsa.PrivateKey + + signer jose.Signer + + Issuer string +} + +// Start starts an unstarted server. +func (s *Server) Start() { + s.started.Do(func() { + e := echo.New() + + e.GET("/.well-known/openid-configuration", s.handleOIDC) + e.GET("/.well-known/jwks.json", s.handleJWKS) + + listener, err := net.Listen("tcp", ":0") + + require.NoError(s.t, err) + + srv := &http.Server{ + Handler: e, + } + + go s.serve(srv, listener) + + s.cleanup = append(s.cleanup, func() { + _ = srv.Close() //nolint:errcheck // error check not needed + }) + + s.engine = e + s.Issuer = fmt.Sprintf("http://127.0.0.1:%d", listener.Addr().(*net.TCPAddr).Port) + s.hasStarted = true + }) +} + +// Stop shuts down the auth server. +func (s *Server) Stop() { + // Don't stop unless we've started. + if !s.hasStarted { + return + } + + s.stopped.Do(func() { + for i := len(s.cleanup) - 1; i > 0; i-- { + s.cleanup[i]() + } + }) +} + +func (s *Server) serve(srv *http.Server, listener net.Listener) { + err := srv.Serve(listener) + + switch { + case err == nil: + case errors.Is(err, http.ErrServerClosed): + default: + s.t.Error("unexpected error from Server:", err) + s.t.Fail() + } +} + +func (s *Server) handleOIDC(c echo.Context) error { + return c.JSON(http.StatusOK, echo.Map{ + "jwks_uri": fmt.Sprintf(s.Issuer + "/.well-known/jwks.json"), + }) +} + +func (s *Server) handleJWKS(c echo.Context) error { + return c.JSON(http.StatusOK, jose.JSONWebKeySet{ + Keys: []jose.JSONWebKey{ + { + KeyID: s.kid, + Key: s.privKey, + }, + }, + }) +} + +func (s *Server) buildClaims(options ...ClaimOption) jwt.Builder { + claims := jwt.Claims{ + Issuer: s.Issuer, + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + } + + for _, opt := range options { + opt(&claims) + } + + return jwt.Signed(s.signer).Claims(claims) +} + +// TSignSubject returns a new token string with the provided subject. +// Additional claims may be provided as options. +// Any errors produced will result in the passed test argument failing. +func (s *Server) TSignSubject(t *testing.T, subject string, options ...ClaimOption) string { + options = append(options, Subject(subject)) + + claims := s.buildClaims(options...) + + token, err := claims.Serialize() + + require.NoError(t, err) + + return token +} + +// SignSubject returns a new token string with the provided subject. +// Additional claims may be provided as options. +// Any errors produced will result in the test passed when initializing Server to fail. +func (s *Server) SignSubject(subject string, options ...ClaimOption) string { + return s.TSignSubject(s.t, subject, options...) +} + +// NewUnstartedServer creates a new Server without starting it. +func NewUnstartedServer(t *testing.T) *Server { + t.Helper() + + kid := "test" + key, err := rsa.GenerateKey(rand.Reader, keySize) + + require.NoError(t, err) + + signer, err := jose.NewSigner( + jose.SigningKey{ + Algorithm: jose.RS256, + Key: key, + }, (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", kid), + ) + + require.NoError(t, err) + + return &Server{ + t: t, + kid: kid, + privKey: key, + signer: signer, + } +} + +// NewServer creates a new Server and starts it. +func NewServer(t *testing.T) *Server { + s := NewUnstartedServer(t) + + s.Start() + + return s +} diff --git a/middleware/echo/iamruntimemiddleware/authentication.go b/middleware/echo/iamruntimemiddleware/authentication.go new file mode 100644 index 0000000..31123f0 --- /dev/null +++ b/middleware/echo/iamruntimemiddleware/authentication.go @@ -0,0 +1,61 @@ +package iamruntimemiddleware + +import ( + "errors" + "fmt" + + "github.com/golang-jwt/jwt/v5" + "github.com/labstack/echo/v4" + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authentication" + "google.golang.org/grpc" + + "github.com/metal-toolbox/iam-runtime-contrib/iamruntime" + "github.com/metal-toolbox/iam-runtime-contrib/internal" +) + +func setAuthenticationContext(c echo.Context) error { + bearer, err := internal.GetBearerToken(c.Request()) + if err != nil { + return echo.ErrBadRequest.WithInternal(fmt.Errorf("%w: %s", iamruntime.AuthError, err)) + } + + ctx := c.Request().Context() + + token, _, err := jwt.NewParser().ParseUnverified(bearer, jwt.MapClaims{}) + if err != nil { + return echo.ErrBadRequest.WithInternal(fmt.Errorf("%w: failed to parse jwt: %w", iamruntime.AuthError, err)) + } + + subject, err := token.Claims.GetSubject() + if err != nil { + return echo.ErrBadRequest.WithInternal(fmt.Errorf("%w: failed to get subject from jwt: %w", iamruntime.AuthError, err)) + } + + ctx = iamruntime.SetContextToken(ctx, token) + ctx = iamruntime.SetContextSubject(ctx, subject) + + c.SetRequest(c.Request().WithContext(ctx)) + + return ValidateCredential(c, &authentication.ValidateCredentialRequest{ + Credential: bearer, + }) +} + +// ValidateCredential executes an access request on the runtime in the context with the provided actions. +// If any error is returned, the error is converted to an echo error with a proper status code. +func ValidateCredential(c echo.Context, in *authentication.ValidateCredentialRequest, opts ...grpc.CallOption) error { + if err := iamruntime.ContextValidateCredential(c.Request().Context(), in, opts...); err != nil { + switch { + case errors.Is(err, iamruntime.ErrTokenNotFound): + return echo.ErrBadRequest.WithInternal(err) + case errors.Is(err, iamruntime.ErrRuntimeNotFound), errors.Is(err, iamruntime.ErrCredentialValidationRequestFailed): + return echo.ErrInternalServerError.WithInternal(err) + case errors.Is(err, iamruntime.ErrInvalidCredentials): + return echo.ErrUnauthorized.WithInternal(err) + default: + return echo.ErrInternalServerError.WithInternal(fmt.Errorf("unknown error: %w", err)) + } + } + + return nil +} diff --git a/middleware/echo/iamruntimemiddleware/authentication_test.go b/middleware/echo/iamruntimemiddleware/authentication_test.go new file mode 100644 index 0000000..22e635c --- /dev/null +++ b/middleware/echo/iamruntimemiddleware/authentication_test.go @@ -0,0 +1,115 @@ +package iamruntimemiddleware + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v4" + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authentication" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + "github.com/metal-toolbox/iam-runtime-contrib/iamruntime" + "github.com/metal-toolbox/iam-runtime-contrib/internal/testauth" + "github.com/metal-toolbox/iam-runtime-contrib/mockruntime" +) + +func TestValidateCredential(t *testing.T) { + authsrv := testauth.NewServer(t) + t.Cleanup(authsrv.Stop) + + testCases := []struct { + name string + authenticationResponse *authentication.ValidateCredentialResponse + authenticationError error + expectError *echo.HTTPError + }{ + { + "permitted", + &authentication.ValidateCredentialResponse{Result: authentication.ValidateCredentialResponse_RESULT_VALID}, + nil, + nil, + }, + { + "denied", + &authentication.ValidateCredentialResponse{Result: authentication.ValidateCredentialResponse_RESULT_INVALID}, + nil, + echo.ErrUnauthorized, + }, + { + "failed request", + nil, + grpc.ErrServerStopped, + echo.ErrInternalServerError, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + runtime := new(mockruntime.MockRuntime) + + runtime.Mock.On("ValidateCredential", "some subject").Return(tc.authenticationResponse, tc.authenticationError) + + engine := echo.New() + + engine.Debug = true + + ctx := context.Background() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "/test", nil) + require.NoError(t, err) + + resp := httptest.NewRecorder() + + c := engine.NewContext(req, resp) + + c.SetRequest(c.Request().WithContext(iamruntime.SetContextRuntime(c.Request().Context(), runtime))) + + err = ValidateCredential(c, &authentication.ValidateCredentialRequest{ + Credential: authsrv.TSignSubject(t, "some subject"), + }) + + if tc.expectError != nil { + require.Error(t, err, "expected error to be returned") + require.IsType(t, tc.expectError, err, "expected echo error") + + echoerr := err.(*echo.HTTPError) + + require.Equal(t, tc.expectError.Code, echoerr.Code, "unexpected echo http code") + } else { + assert.NoError(t, err, "expected no error to be returned") + } + + runtime.Mock.AssertExpectations(t) + }) + } +} + +func ExampleValidateCredential() { + middleware, _ := NewConfig().ToMiddleware() + + engine := echo.New() + + engine.Use(middleware) + + engine.GET("/user", func(c echo.Context) error { + otherToken := c.QueryParam("check-token") + + if err := ValidateCredential(c, &authentication.ValidateCredentialRequest{Credential: otherToken}); err != nil { + if errors.Is(err, iamruntime.ErrInvalidCredentials) { + return fmt.Errorf("%w: other credentials are invalid", err) + } + + return err + } + + return c.String(http.StatusOK, "other token is valid") + }) + + _ = http.ListenAndServe(":8080", engine) +} diff --git a/middleware/echo/iamruntimemiddleware/authorization.go b/middleware/echo/iamruntimemiddleware/authorization.go new file mode 100644 index 0000000..fe85bf2 --- /dev/null +++ b/middleware/echo/iamruntimemiddleware/authorization.go @@ -0,0 +1,71 @@ +package iamruntimemiddleware + +import ( + "errors" + "fmt" + + "github.com/labstack/echo/v4" + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authorization" + "google.golang.org/grpc" + + "github.com/metal-toolbox/iam-runtime-contrib/iamruntime" +) + +func setRuntimeContext(r Runtime, c echo.Context) error { + ctx := iamruntime.SetContextRuntimeAny(c.Request().Context(), r) + + c.SetRequest(c.Request().WithContext(ctx)) + + return nil +} + +// CheckAccess executes an access request on the runtime in the context with the provided actions. +// If any error is returned, the error is converted to an echo error with a proper status code. +func CheckAccess(c echo.Context, actions []*authorization.AccessRequestAction, opts ...grpc.CallOption) error { + if err := iamruntime.ContextCheckAccess(c.Request().Context(), actions, opts...); err != nil { + switch { + case errors.Is(err, iamruntime.ErrTokenNotFound): + return echo.ErrBadRequest.WithInternal(err) + case errors.Is(err, iamruntime.ErrRuntimeNotFound), errors.Is(err, iamruntime.ErrAccessCheckFailed): + return echo.ErrInternalServerError.WithInternal(err) + case errors.Is(err, iamruntime.ErrAccessDenied): + return echo.ErrForbidden.WithInternal(err) + default: + return echo.ErrInternalServerError.WithInternal(fmt.Errorf("unknown error: %w", err)) + } + } + + return nil +} + +// CreateRelationships executes a create relationship request on the runtime in the context. +// If any error is returned, the error is converted to an echo error with a proper status code. +func CreateRelationships(c echo.Context, in *authorization.CreateRelationshipsRequest, opts ...grpc.CallOption) (*authorization.CreateRelationshipsResponse, error) { + resp, err := iamruntime.ContextCreateRelationships(c.Request().Context(), in, opts...) + if err != nil { + switch { + case errors.Is(err, iamruntime.ErrRuntimeNotFound), errors.Is(err, iamruntime.ErrRelationshipRequestFailed): + return nil, echo.ErrInternalServerError.WithInternal(err) + default: + return nil, echo.ErrInternalServerError.WithInternal(fmt.Errorf("unknown error: %w", err)) + } + } + + return resp, nil +} + +// DeleteRelationships executes a delete relationship request on the runtime in the context. +// If any error is returned, the error is converted to an echo error with a proper status code. +func DeleteRelationships(c echo.Context, in *authorization.DeleteRelationshipsRequest, opts ...grpc.CallOption) (*authorization.DeleteRelationshipsResponse, error) { + resp, err := iamruntime.ContextDeleteRelationships(c.Request().Context(), in, opts...) + if err != nil { + switch { + case errors.Is(err, iamruntime.ErrRuntimeNotFound), errors.Is(err, iamruntime.ErrRelationshipRequestFailed): + return nil, echo.ErrInternalServerError.WithInternal(err) + default: + return nil, echo.ErrInternalServerError.WithInternal(fmt.Errorf("unknown error: %w", err)) + } + } + + return resp, nil +} diff --git a/middleware/echo/iamruntimemiddleware/authorization_test.go b/middleware/echo/iamruntimemiddleware/authorization_test.go new file mode 100644 index 0000000..705e39f --- /dev/null +++ b/middleware/echo/iamruntimemiddleware/authorization_test.go @@ -0,0 +1,431 @@ +package iamruntimemiddleware + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v4" + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authentication" + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authorization" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + "github.com/metal-toolbox/iam-runtime-contrib/iamruntime" + "github.com/metal-toolbox/iam-runtime-contrib/internal/testauth" + "github.com/metal-toolbox/iam-runtime-contrib/mockruntime" +) + +func TestCheckAccess(t *testing.T) { + authsrv := testauth.NewServer(t) + t.Cleanup(authsrv.Stop) + + testCases := []struct { + name string + actions []*authorization.AccessRequestAction + returnAccessResult authorization.CheckAccessResponse_Result + returnAccessError error + expectCalled map[string][]string + expectStatus int + expectBody map[string]any + }{ + { + "permitted", + []*authorization.AccessRequestAction{ + { + ResourceId: "testten-abc123", + Action: "action_one", + }, + { + ResourceId: "testten-abc123", + Action: "action_two", + }, + { + ResourceId: "testten-def456", + Action: "action_one", + }, + }, + authorization.CheckAccessResponse_RESULT_ALLOWED, + nil, + map[string][]string{ + "testten-abc123": {"action_one", "action_two"}, + "testten-def456": {"action_one"}, + }, + http.StatusOK, + map[string]any{ + "success": true, + }, + }, + { + "denied", + []*authorization.AccessRequestAction{ + { + ResourceId: "testten-abc123", + Action: "action_one", + }, + }, + authorization.CheckAccessResponse_RESULT_DENIED, + nil, + map[string][]string{"testten-abc123": {"action_one"}}, + http.StatusForbidden, + map[string]any{ + "message": "Forbidden", + "error": "code=403, message=Forbidden, internal=iam-runtime error: access: denied", + }, + }, + { + "error", + []*authorization.AccessRequestAction{ + { + ResourceId: "testten-abc123", + Action: "action_one", + }, + }, + 0, + grpc.ErrServerStopped, + map[string][]string{"testten-abc123": {"action_one"}}, + http.StatusInternalServerError, + map[string]any{ + "message": "Internal Server Error", + "error": "code=500, message=Internal Server Error, internal=iam-runtime error: access: failed to check access: grpc: the server has been stopped", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + runtime := new(mockruntime.MockRuntime) + + runtime.Mock.On("ValidateCredential", "some subject").Return(&authentication.ValidateCredentialResponse{ + Result: authentication.ValidateCredentialResponse_RESULT_VALID, + }, nil) + + runtime.Mock.On("CheckAccess", tc.expectCalled).Return(tc.returnAccessResult, tc.returnAccessError) + + config := NewConfig().WithRuntime(runtime) + + middleware, err := config.ToMiddleware() + require.NoError(t, err, "unexpected error building middleware") + + engine := echo.New() + + engine.Debug = true + + engine.Use(middleware) + + engine.GET("/test", func(c echo.Context) error { + if err := CheckAccess(c, tc.actions); err != nil { + return err + } + + return c.JSON(http.StatusOK, echo.Map{ + "success": true, + }) + }) + + ctx := context.Background() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "/test", nil) + require.NoError(t, err) + + req.Header.Add("Authorization", "Bearer "+authsrv.TSignSubject(t, "some subject")) + + resp := httptest.NewRecorder() + + engine.ServeHTTP(resp, req) + + runtime.Mock.AssertExpectations(t) + + assert.Equal(t, tc.expectStatus, resp.Code, "unexpected status code returned") + + var body map[string]any + + err = json.Unmarshal(resp.Body.Bytes(), &body) + require.NoError(t, err, "unexpected error decoding body") + + assert.Equal(t, tc.expectBody, body, "unexpected body returned") + }) + } +} + +func TestCreateRelationships(t *testing.T) { + testCases := []struct { + name string + request *authorization.CreateRelationshipsRequest + requestError error + expectCalled map[string][]string + expectError *echo.HTTPError + }{ + { + "created", + &authorization.CreateRelationshipsRequest{ + ResourceId: "testten-abc123", + Relationships: []*authorization.Relationship{ + { + Relation: "parent", + SubjectId: "testten-root123", + }, + }, + }, + nil, + map[string][]string{ + "parent": {"testten-root123"}, + }, + nil, + }, + { + "failed", + &authorization.CreateRelationshipsRequest{ + ResourceId: "testten-abc123", + Relationships: []*authorization.Relationship{ + { + Relation: "parent", + SubjectId: "testten-root123", + }, + }, + }, + grpc.ErrServerStopped, + map[string][]string{ + "parent": {"testten-root123"}, + }, + echo.ErrInternalServerError, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + runtime := new(mockruntime.MockRuntime) + + runtime.Mock.On("CreateRelationships", tc.request.ResourceId, tc.expectCalled).Return(tc.requestError) + + engine := echo.New() + + engine.Debug = true + + ctx := context.Background() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "/test", nil) + require.NoError(t, err) + + resp := httptest.NewRecorder() + + c := engine.NewContext(req, resp) + + c.SetRequest(c.Request().WithContext(iamruntime.SetContextRuntime(c.Request().Context(), runtime))) + + _, err = CreateRelationships(c, tc.request) + + if tc.expectError != nil { + require.Error(t, err, "expected error to be returned") + require.IsType(t, tc.expectError, err, "expected echo error") + + echoerr := err.(*echo.HTTPError) + + require.Equal(t, tc.expectError.Code, echoerr.Code, "unexpected echo http code") + } else { + assert.NoError(t, err, "expected no error to be returned") + } + + runtime.Mock.AssertExpectations(t) + }) + } +} + +func TestDeleteRelationships(t *testing.T) { + testCases := []struct { + name string + request *authorization.DeleteRelationshipsRequest + requestError error + expectCalled map[string][]string + expectError *echo.HTTPError + }{ + { + "deleted", + &authorization.DeleteRelationshipsRequest{ + ResourceId: "testten-abc123", + Relationships: []*authorization.Relationship{ + { + Relation: "parent", + SubjectId: "testten-root123", + }, + }, + }, + nil, + map[string][]string{ + "parent": {"testten-root123"}, + }, + nil, + }, + { + "failed", + &authorization.DeleteRelationshipsRequest{ + ResourceId: "testten-abc123", + Relationships: []*authorization.Relationship{ + { + Relation: "parent", + SubjectId: "testten-root123", + }, + }, + }, + grpc.ErrServerStopped, + map[string][]string{ + "parent": {"testten-root123"}, + }, + echo.ErrInternalServerError, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + runtime := new(mockruntime.MockRuntime) + + runtime.Mock.On("DeleteRelationships", tc.request.ResourceId, tc.expectCalled).Return(tc.requestError) + + engine := echo.New() + + engine.Debug = true + + ctx := context.Background() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "/test", nil) + require.NoError(t, err) + + resp := httptest.NewRecorder() + + c := engine.NewContext(req, resp) + + c.SetRequest(c.Request().WithContext(iamruntime.SetContextRuntime(c.Request().Context(), runtime))) + + _, err = DeleteRelationships(c, tc.request) + + if tc.expectError != nil { + require.Error(t, err, "expected error to be returned") + require.IsType(t, tc.expectError, err, "expected echo error") + + echoerr := err.(*echo.HTTPError) + + require.Equal(t, tc.expectError.Code, echoerr.Code, "unexpected echo http code") + } else { + assert.NoError(t, err, "expected no error to be returned") + } + + runtime.Mock.AssertExpectations(t) + }) + } +} + +func ExampleCheckAccess() { + middleware, _ := NewConfig().ToMiddleware() + + engine := echo.New() + + engine.Use(middleware) + + engine.GET("/resources/:resource_id", func(c echo.Context) error { + check := []*authorization.AccessRequestAction{ + {ResourceId: c.Param("resource_id"), Action: "resource_get"}, + } + + if err := CheckAccess(c, check); err != nil { + return err + } + + return c.String(http.StatusOK, "user has access to resource") + }) + + _ = http.ListenAndServe(":8080", engine) +} + +// StorageResource is used in examples. +type StorageResource struct { + ID string + ParentResourceID string +} + +// GetResourceFromRequest is used in examples. +func GetResourceFromRequest(_ echo.Context) StorageResource { + return StorageResource{ + ID: "testten-abc123", + ParentResourceID: "testten-root123", + } +} + +// CreateResourceFromRequest is used in examples. +func CreateResourceFromRequest(_ echo.Context) StorageResource { + return StorageResource{ + ID: "testten-abc123", + ParentResourceID: "testten-root123", + } +} + +// DeleteResourceFromRequest is used in examples. +func DeleteResourceFromRequest(_ echo.Context) error { + return nil +} + +func ExampleCreateRelationships() { + middleware, _ := NewConfig().ToMiddleware() + + engine := echo.New() + + engine.Use(middleware) + + engine.POST("/resources", func(c echo.Context) error { + resource := CreateResourceFromRequest(c) + + relationRequest := &authorization.CreateRelationshipsRequest{ + ResourceId: resource.ID, + Relationships: []*authorization.Relationship{ + { + Relation: "parent", + SubjectId: resource.ParentResourceID, + }, + }, + } + + if _, err := CreateRelationships(c, relationRequest); err != nil { + return err + } + + return c.String(http.StatusOK, "resource created with relationships") + }) + + _ = http.ListenAndServe(":8080", engine) +} + +func ExampleDeleteRelationships() { + middleware, _ := NewConfig().ToMiddleware() + + engine := echo.New() + + engine.Use(middleware) + + engine.DELETE("/resources/:resource_id", func(c echo.Context) error { + resource := GetResourceFromRequest(c) + + if err := DeleteResourceFromRequest(c); err != nil { + return err + } + + relationRequest := &authorization.DeleteRelationshipsRequest{ + ResourceId: resource.ID, + Relationships: []*authorization.Relationship{ + { + Relation: "parent", + SubjectId: resource.ParentResourceID, + }, + }, + } + + if _, err := DeleteRelationships(c, relationRequest); err != nil { + return err + } + + return c.String(http.StatusOK, "resource created with relationships") + }) + + _ = http.ListenAndServe(":8080", engine) +} diff --git a/middleware/echo/iamruntimemiddleware/config.go b/middleware/echo/iamruntimemiddleware/config.go new file mode 100644 index 0000000..fe9bfac --- /dev/null +++ b/middleware/echo/iamruntimemiddleware/config.go @@ -0,0 +1,59 @@ +package iamruntimemiddleware + +import ( + "github.com/labstack/echo/v4/middleware" + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authentication" + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authorization" +) + +const defaultRuntimePath = "/tmp/runtime.sock" + +// Runtime defines the required methods for a supported runtime. +type Runtime interface { + authentication.AuthenticationClient + authorization.AuthorizationClient +} + +// Config defines configuration for the iam-runtime middleware. +// Build the echo middleware by calling [Config.ToMiddleware]() +type Config struct { + // Skipper defines a function to skip middleware. + Skipper middleware.Skipper + + // Socket defines the iam runtime socket path. + // Default is /tmp/runtime.sock + // Not used if Runtime is defined. + Socket string + + // Runtime specifies the middleware will use. + // If no runtime is provided, a new runtime client is created using the Socket path. + Runtime Runtime + + runtime Runtime +} + +// WithSkipper returns a new [Config] with the provided skipper set. +func (c Config) WithSkipper(value middleware.Skipper) Config { + c.Skipper = value + + return c +} + +// WithSocket returns a new [Config] with the provided socket set. +func (c Config) WithSocket(value string) Config { + c.Socket = value + + return c +} + +// WithRuntime returns a new [Config] with the provided runtime set. +func (c Config) WithRuntime(value Runtime) Config { + c.Runtime = value + + return c +} + +// NewConfig returns a new empty config. +func NewConfig() Config { + return Config{} +} diff --git a/middleware/echo/iamruntimemiddleware/context.go b/middleware/echo/iamruntimemiddleware/context.go new file mode 100644 index 0000000..668a6e0 --- /dev/null +++ b/middleware/echo/iamruntimemiddleware/context.go @@ -0,0 +1,36 @@ +package iamruntimemiddleware + +import ( + "github.com/golang-jwt/jwt/v5" + "github.com/labstack/echo/v4" + + "github.com/metal-toolbox/iam-runtime-contrib/iamruntime" +) + +// ContextRuntime retrieves the iam runtime from the context. +// If the runtime is not found in the provided context, nil is returned. +// +// Use ContextRuntime() or ContextRuntimeAny() from iamruntime if a stdlib context is being used. +func ContextRuntime(c echo.Context) Runtime { + if runtime, ok := iamruntime.ContextRuntimeAny(c.Request().Context()).(Runtime); ok { + return runtime + } + + return nil +} + +// ContextToken retrieves the decoded jwt token from the provided echo context. +// If the token is not found in the provided context, nil is returned. +// +// Use ContextToken() from iamruntime if a stdlib context is being used. +func ContextToken(c echo.Context) *jwt.Token { + return iamruntime.ContextToken(c.Request().Context()) +} + +// ContextSubject retrieves the subject from the provided echo context. +// If the subject is not found in the provided context, an empty string is returned. +// +// Use ContextSubject() from iamruntime if a stdlib context is being used. +func ContextSubject(c echo.Context) string { + return iamruntime.ContextSubject(c.Request().Context()) +} diff --git a/middleware/echo/iamruntimemiddleware/doc.go b/middleware/echo/iamruntimemiddleware/doc.go new file mode 100644 index 0000000..763416a --- /dev/null +++ b/middleware/echo/iamruntimemiddleware/doc.go @@ -0,0 +1,2 @@ +// Package iamruntimemiddleware builds an echo middleware which validates request authorization tokens. +package iamruntimemiddleware diff --git a/middleware/echo/iamruntimemiddleware/middleware.go b/middleware/echo/iamruntimemiddleware/middleware.go new file mode 100644 index 0000000..8573a35 --- /dev/null +++ b/middleware/echo/iamruntimemiddleware/middleware.go @@ -0,0 +1,55 @@ +package iamruntimemiddleware + +import ( + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + + "github.com/metal-toolbox/iam-runtime-contrib/iamruntime" +) + +// ToMiddleware builds a new echo middleware function from the defined config. +// If no runtime client is defined, a default one is initialized. +// The default runtime will use the configured Socket path to connect to the runtime server. +// If no Socket is provided, the default socket path is used (/tmp/runtime.sock) +func (c Config) ToMiddleware() (echo.MiddlewareFunc, error) { + if c.Skipper == nil { + c.Skipper = middleware.DefaultSkipper + } + + c.runtime = c.Runtime + + if c.Runtime == nil { + if c.Socket == "" { + c.Socket = defaultRuntimePath + } + + runtime, err := iamruntime.NewClient(c.Socket) + if err != nil { + return nil, err + } + + c.runtime = runtime + } + + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(ctx echo.Context) error { + if c.Skipper(ctx) { + return next(ctx) + } + + if err := setRuntimeContext(c.runtime, ctx); err != nil { + ctx.Error(err) + + return err + } + + if err := setAuthenticationContext(ctx); err != nil { + ctx.Error(err) + + return err + } + + return next(ctx) + } + }, nil +} diff --git a/middleware/echo/iamruntimemiddleware/middleware_test.go b/middleware/echo/iamruntimemiddleware/middleware_test.go new file mode 100644 index 0000000..7e49b87 --- /dev/null +++ b/middleware/echo/iamruntimemiddleware/middleware_test.go @@ -0,0 +1,117 @@ +package iamruntimemiddleware + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v4" + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authentication" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/metal-toolbox/iam-runtime-contrib/internal/testauth" + "github.com/metal-toolbox/iam-runtime-contrib/mockruntime" +) + +func TestConfig_ToMiddleware(t *testing.T) { + authsrv := testauth.NewServer(t) + t.Cleanup(authsrv.Stop) + + testCases := []struct { + name string + authenticationResponse authentication.ValidateCredentialResponse_Result + expectStatus int + expectBody map[string]any + }{ + { + "valid", + authentication.ValidateCredentialResponse_RESULT_VALID, + http.StatusOK, + map[string]any{ + "token_subject": "some subject", + "subject": "some subject", + }, + }, + { + "invalid", + authentication.ValidateCredentialResponse_RESULT_INVALID, + http.StatusUnauthorized, + map[string]any{ + "message": "Unauthorized", + "error": "code=401, message=Unauthorized, internal=iam-runtime error: auth: invalid credentials", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + runtime := new(mockruntime.MockRuntime) + + runtime.Mock.On("ValidateCredential", "some subject").Return(&authentication.ValidateCredentialResponse{ + Result: tc.authenticationResponse, + }, nil) + + config := NewConfig().WithRuntime(runtime) + + middleware, err := config.ToMiddleware() + require.NoError(t, err, "unexpected error building middleware") + + engine := echo.New() + + engine.Debug = true + + engine.Use(middleware) + + engine.GET("/test", func(c echo.Context) error { + subject, err := ContextToken(c).Claims.GetSubject() + if err != nil { + return echo.ErrNotAcceptable.WithInternal(err) + } + + return c.JSON(http.StatusOK, echo.Map{ + "token_subject": subject, + "subject": ContextSubject(c), + }) + }) + + ctx := context.Background() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "/test", nil) + require.NoError(t, err) + + req.Header.Add("Authorization", "Bearer "+authsrv.TSignSubject(t, "some subject")) + + resp := httptest.NewRecorder() + + engine.ServeHTTP(resp, req) + + runtime.Mock.AssertExpectations(t) + + assert.Equal(t, tc.expectStatus, resp.Code, "unexpected status code returned") + + var body map[string]any + + err = json.Unmarshal(resp.Body.Bytes(), &body) + require.NoError(t, err, "unexpected error decoding body") + + assert.Equal(t, tc.expectBody, body, "unexpected body returned") + }) + } +} + +func ExampleConfig_ToMiddleware() { + middleware, _ := NewConfig().ToMiddleware() + + engine := echo.New() + + engine.Use(middleware) + + engine.GET("/user", func(c echo.Context) error { + return c.String(http.StatusOK, "welcome "+ContextSubject(c)) + }) + + _ = http.ListenAndServe(":8080", engine) +} diff --git a/mockruntime/authentication.go b/mockruntime/authentication.go new file mode 100644 index 0000000..2601dab --- /dev/null +++ b/mockruntime/authentication.go @@ -0,0 +1,26 @@ +package mockruntime + +import ( + "context" + + "github.com/golang-jwt/jwt/v5" + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authentication" + "google.golang.org/grpc" +) + +// ValidateCredential mocks iam-runtime authentication.ValidateCredential +func (r *MockRuntime) ValidateCredential(_ context.Context, in *authentication.ValidateCredentialRequest, _ ...grpc.CallOption) (*authentication.ValidateCredentialResponse, error) { + var subject string + + token, _, err := jwt.NewParser().ParseUnverified(in.Credential, jwt.MapClaims{}) + if err == nil { + subject, err = token.Claims.GetSubject() + if err != nil { + return nil, err + } + } + + args := r.Mock.Called(subject) + + return args.Get(0).(*authentication.ValidateCredentialResponse), args.Error(1) +} diff --git a/mockruntime/authorization.go b/mockruntime/authorization.go new file mode 100644 index 0000000..df9da39 --- /dev/null +++ b/mockruntime/authorization.go @@ -0,0 +1,61 @@ +package mockruntime + +import ( + "context" + + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authorization" + "google.golang.org/grpc" +) + +// CheckAccess mocks iam-runtime authorization.CheckAccess +func (r *MockRuntime) CheckAccess(_ context.Context, in *authorization.CheckAccessRequest, _ ...grpc.CallOption) (*authorization.CheckAccessResponse, error) { + actions := make(map[string][]string) + + for _, request := range in.Actions { + actions[request.ResourceId] = append(actions[request.ResourceId], request.Action) + } + + args := r.Mock.Called(actions) + + if err := args.Error(1); err != nil { + return nil, err + } + + result := args.Get(0).(authorization.CheckAccessResponse_Result) + + return &authorization.CheckAccessResponse{Result: result}, nil +} + +// CreateRelationships mocks iam-runtime authorization.CreateRelationships +func (r *MockRuntime) CreateRelationships(_ context.Context, in *authorization.CreateRelationshipsRequest, _ ...grpc.CallOption) (*authorization.CreateRelationshipsResponse, error) { + relations := make(map[string][]string) + + for _, rel := range in.Relationships { + relations[rel.Relation] = append(relations[rel.Relation], rel.SubjectId) + } + + args := r.Mock.Called(in.ResourceId, relations) + + if err := args.Error(0); err != nil { + return nil, err + } + + return &authorization.CreateRelationshipsResponse{}, nil +} + +// DeleteRelationships mocks iam-runtime authorization.DeleteRelationships +func (r *MockRuntime) DeleteRelationships(_ context.Context, in *authorization.DeleteRelationshipsRequest, _ ...grpc.CallOption) (*authorization.DeleteRelationshipsResponse, error) { + relations := make(map[string][]string) + + for _, rel := range in.Relationships { + relations[rel.Relation] = append(relations[rel.Relation], rel.SubjectId) + } + + args := r.Mock.Called(in.ResourceId, relations) + + if err := args.Error(0); err != nil { + return nil, err + } + + return &authorization.DeleteRelationshipsResponse{}, nil +} diff --git a/mockruntime/identity.go b/mockruntime/identity.go new file mode 100644 index 0000000..44a90bd --- /dev/null +++ b/mockruntime/identity.go @@ -0,0 +1,15 @@ +package mockruntime + +import ( + "context" + + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/identity" + "google.golang.org/grpc" +) + +// GetAccessToken mocks iam-runtime identity.GetAccessToken +func (r *MockRuntime) GetAccessToken(_ context.Context, _ *identity.GetAccessTokenRequest, _ ...grpc.CallOption) (*identity.GetAccessTokenResponse, error) { + args := r.Mock.Called() + + return args.Get(0).(*identity.GetAccessTokenResponse), args.Error(1) +} diff --git a/mockruntime/runtime.go b/mockruntime/runtime.go new file mode 100644 index 0000000..bb99dd5 --- /dev/null +++ b/mockruntime/runtime.go @@ -0,0 +1,18 @@ +// Package mockruntime mocks iam-runtime clients. +package mockruntime + +import ( + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authentication" + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/authorization" + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/identity" + "github.com/stretchr/testify/mock" +) + +// MockRuntime mocks iam-runtime clients. +type MockRuntime struct { + authentication.AuthenticationClient + authorization.AuthorizationClient + identity.IdentityClient + + mock.Mock +} diff --git a/oauth2/filetokensource/doc.go b/oauth2/filetokensource/doc.go new file mode 100644 index 0000000..de69ce3 --- /dev/null +++ b/oauth2/filetokensource/doc.go @@ -0,0 +1,4 @@ +// Package filetokensource implements the oauth2.TokenSource interface for tokens sourced from a file. +// +// This package can be used for sourcing Kubernetes ServiceAccount tokens to interact directly with Kube API. +package filetokensource diff --git a/oauth2/filetokensource/handler.go b/oauth2/filetokensource/handler.go new file mode 100644 index 0000000..f8a3763 --- /dev/null +++ b/oauth2/filetokensource/handler.go @@ -0,0 +1,92 @@ +package filetokensource + +import ( + "sync" + "time" + + "go.uber.org/zap" + "golang.org/x/oauth2" +) + +const ( + defaultWatchInterval = 5 * time.Minute + defaultWaitTimeout = time.Minute +) + +// Handler loads and refreshes tokens from a file every 5 minutes. +type Handler struct { + logger *zap.Logger + path string + init sync.Once + onDemand bool + token *oauth2.Token + tokMu sync.RWMutex + changed time.Time + changedMu sync.RWMutex + err error + errMu sync.RWMutex + doneCh chan struct{} + stopped bool + + watchInterval time.Duration + waitTimeout time.Duration + + OnChange func(*oauth2.Token) +} + +// Error returns the last error if one was generated while attempting to read the token file. +// If no error was generated previous errors will be cleared. +func (h *Handler) Error() error { + h.errMu.RLock() + defer h.errMu.RUnlock() + + return h.err +} + +func (h *Handler) setError(err error) { + h.errMu.Lock() + defer h.errMu.Unlock() + + h.err = err +} + +// Stop cleans up the token handler. +func (h *Handler) Stop() { + if !h.onDemand && !h.stopped { + h.stopped = true + + close(h.doneCh) + + h.logger.Info("token handling stopped") + } +} + +// Load creates a new token handler for the provided token path. +// The path is loaded and refreshed every minute. +// Call [Handler.Token]() to get the latest token. +// Any error getting the a token is available at [Handler.Error]() +func Load(path string, options ...Option) (*Handler, error) { + handler := &Handler{ + logger: zap.NewNop(), + path: path, + + watchInterval: defaultWatchInterval, + waitTimeout: defaultWaitTimeout, + } + + for _, opt := range options { + if err := opt(handler); err != nil { + return nil, err + } + } + + handler.logger = handler.logger.With(zap.String("token_file", path)) + + if !handler.onDemand { + handler.doneCh = make(chan struct{}, 1) + + go handler.watchToken() + } + + return handler, nil +} diff --git a/oauth2/filetokensource/handler_test.go b/oauth2/filetokensource/handler_test.go new file mode 100644 index 0000000..9f5fe18 --- /dev/null +++ b/oauth2/filetokensource/handler_test.go @@ -0,0 +1,20 @@ +package filetokensource + +import ( + "fmt" + + "github.com/golang-jwt/jwt/v5" +) + +func ExampleLoad() { + handler, _ := Load("/path/to/token") + defer handler.Stop() + + token, _ := handler.Token() + + jwt, _, _ := jwt.NewParser().ParseUnverified(token.AccessToken, jwt.MapClaims{}) + + subject, _ := jwt.Claims.GetSubject() + + fmt.Println("Token Subject:", subject) +} diff --git a/oauth2/filetokensource/options.go b/oauth2/filetokensource/options.go new file mode 100644 index 0000000..3181832 --- /dev/null +++ b/oauth2/filetokensource/options.go @@ -0,0 +1,75 @@ +package filetokensource + +import ( + "errors" + "time" + + "go.uber.org/zap" + "golang.org/x/oauth2" +) + +const minWatchInterval = 5 * time.Second + +// ErrWatchIntervalTooFrequent is returned when the duration provided is more frequent then 5 seconds. +// 5 minutes is recommended when using for a kubernetes service account token. +var ErrWatchIntervalTooFrequent = errors.New("watch interval is too frequent") + +// Option defines a token handler option. +type Option func(*Handler) error + +// WithLogger sets the logger for the token handler. +func WithLogger(logger *zap.Logger) Option { + return func(h *Handler) error { + if logger == nil { + logger = zap.NewNop() + } + + h.logger = logger + + return nil + } +} + +// WithWatchInterval sets the interval at which the token is checked for updates. +func WithWatchInterval(duration time.Duration) Option { + return func(h *Handler) error { + if duration < minWatchInterval { + return ErrWatchIntervalTooFrequent + } + + h.watchInterval = duration + + return nil + } +} + +// WithWaitTimeout sets the max wait time for the first token to be successfully read before returning an error. +func WithWaitTimeout(duration time.Duration) Option { + return func(h *Handler) error { + if duration < 0 { + duration = time.Minute + } + + h.watchInterval = duration + + return nil + } +} + +// WithOnChange sets the method called each time the source token is changed. +func WithOnChange(fn func(*oauth2.Token)) Option { + return func(h *Handler) error { + h.OnChange = fn + + return nil + } +} + +// OnDemand changes the mode to only load on demand instead of refreshing the token in the background. +func OnDemand() Option { + return func(h *Handler) error { + h.onDemand = true + + return nil + } +} diff --git a/oauth2/filetokensource/token.go b/oauth2/filetokensource/token.go new file mode 100644 index 0000000..a7b2803 --- /dev/null +++ b/oauth2/filetokensource/token.go @@ -0,0 +1,124 @@ +package filetokensource + +import ( + "os" + "time" + + "github.com/golang-jwt/jwt/v5" + "go.uber.org/zap" + "golang.org/x/oauth2" +) + +// fetchToken loads the configured token file. +// +// If no change has been detected, the cached token is not updated. +// If there was previously an error recorded, that error is reset. +// +// If there is a change, the token is decoded to determine the expiry time. +// When there is a change, if Handler.OnChange has been set, +// it will be called in a separate goroutine. +// +// Any errors produced from this method are stored and can be retrieved +// by calling [Handler.Error]() +func (h *Handler) fetchToken() { + tokenb, err := os.ReadFile(h.path) + if err != nil { + h.setError(err) + + h.logger.Error("error reading token file", zap.Error(err)) + + return + } + + var changed bool + + newToken := string(tokenb) + + if h.token == nil || newToken != h.token.AccessToken { + token, _, err := jwt.NewParser().ParseUnverified(newToken, jwt.MapClaims{}) + if err != nil { + h.setError(err) + + h.logger.Error("error parsing jwt", zap.Error(err)) + + return + } + + expiry, err := token.Claims.GetExpirationTime() + if err != nil { + h.setError(err) + + h.logger.Error("error getting expiration time from jwt", zap.Error(err)) + + return + } + + var expiryTime time.Time + + if expiry != nil { + expiryTime = expiry.Time + } + + h.tokMu.Lock() + defer h.tokMu.Unlock() + + h.changedMu.Lock() + defer h.changedMu.Unlock() + + h.token = &oauth2.Token{ + AccessToken: newToken, + TokenType: "Bearer", + Expiry: expiryTime, + } + + h.changed = time.Now().UTC() + changed = true + } + + h.errMu.Lock() + defer h.errMu.Unlock() + + h.err = nil + + h.logger.Debug("token file reloaded", + zap.Bool("token_changed", changed), + ) + + if changed && h.OnChange != nil { + go h.OnChange(h.token) + } +} + +func (h *Handler) readToken() *oauth2.Token { + h.tokMu.RLock() + defer h.tokMu.RUnlock() + + return h.token +} + +// Token returns the string token for the path provided when initializing the handler. +// The token is automatically checked at a set interval (default is 5 minutes) +// The latest token can be retrieved again by calling Token again. +// +// The first call, unless configured for OnDemand, the Token will block waiting for +// the token the first token to be read. +// If no token is returned after 1 minute, an emptry string is returned along with the latest error. +// +// [Handler.Error]() can be called while blocked to check for errors that may have been generated while attempting to load or refresh tokens. +func (h *Handler) Token() (*oauth2.Token, error) { + if h.onDemand { + h.fetchToken() + } else { + h.init.Do(h.waitForToken) + } + + return h.readToken(), h.Error() +} + +// Changed returns the last time the token changed. +func (h *Handler) Changed() time.Time { + h.changedMu.RLock() + defer h.changedMu.RUnlock() + + return h.changed +} diff --git a/oauth2/filetokensource/token_test.go b/oauth2/filetokensource/token_test.go new file mode 100644 index 0000000..8bebc01 --- /dev/null +++ b/oauth2/filetokensource/token_test.go @@ -0,0 +1,159 @@ +package filetokensource + +import ( + "errors" + "os" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "golang.org/x/oauth2" + + "github.com/metal-toolbox/iam-runtime-contrib/internal/testauth" +) + +func TestFetchToken(t *testing.T) { + authsrv := testauth.NewServer(t) + t.Cleanup(authsrv.Stop) + + type fileToken struct { + subject string + expectError error + } + + testCases := []struct { + name string + handler *Handler + tokens []fileToken + expectTokenChanges []string + }{ + { + "no token", + &Handler{ + path: "/tmp/token-not-found", + }, + []fileToken{ + { + expectError: os.ErrNotExist, + }, + }, + nil, + }, + { + "valid token", + &Handler{}, + []fileToken{{ + subject: "token1", + }}, + []string{ + "token1", + }, + }, + { + "multiple tokens", + &Handler{}, + []fileToken{ + {subject: "token1"}, + {subject: "token1"}, + {subject: "token2"}, + }, + []string{ + "token1", + "token2", + }, + }, + { + "bad token skipped", + &Handler{}, + []fileToken{ + {subject: "token1"}, + {subject: "token1"}, + {expectError: jwt.ErrTokenMalformed}, + {subject: "token2"}, + }, + []string{ + "token1", + "token2", + }, + }, + } + + for _, tc := range testCases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + onChangeToken := make(chan string, 1) + defer close(onChangeToken) + + if tc.handler == nil { + tc.handler = &Handler{} + } + + tc.handler.logger = zap.NewNop() + tc.handler.OnChange = func(t *oauth2.Token) { + onChangeToken <- t.AccessToken + } + + for i, token := range tc.tokens { + if tc.handler.path == "" { + file, err := os.CreateTemp(t.TempDir(), "token-*") + + require.NoError(t, err, "unexpected error creating temporary token file") + require.NoError(t, file.Close(), "unexpected error closing temporary token file") + + tc.handler.path = file.Name() + + defer func() { + require.NoError(t, os.Remove(tc.handler.path), "unexpected error removing temporary token file") + }() + } + + if !errors.Is(token.expectError, os.ErrNotExist) { + var jwt string + + if token.subject != "" { + jwt = authsrv.TSignSubject(t, token.subject) + } + + err := os.WriteFile(tc.handler.path, []byte(jwt), 0600) + require.NoError(t, err, "unexpected error writing token") + } + + tc.handler.fetchToken() + + if token.expectError == nil { + assert.NoErrorf(t, tc.handler.Error(), "token %d: no error expected in handler", i) + } else { + require.Errorf(t, tc.handler.Error(), "token %d: expected error set in handler", i) + assert.ErrorIsf(t, tc.handler.Error(), token.expectError, "token %d: unexpected error set in handler", i) + } + } + + for i, expectSubject := range tc.expectTokenChanges { + select { + case token := <-onChangeToken: + jwt, _, err := jwt.NewParser().ParseUnverified(token, jwt.MapClaims{}) + require.NoErrorf(t, err, "token %d: unexpected error decoding token", i) + + subject, err := jwt.Claims.GetSubject() + require.NoErrorf(t, err, "token %d: unexpected error returned getting subject", i) + + assert.Equalf(t, expectSubject, subject, "token %d: unexpected subject returned", i) + case <-time.After(time.Second): + t.Errorf("token %d: timed out waiting for onChange token", i) + } + } + + select { + case <-onChangeToken: + t.Error("unexpected token change received") + case <-time.After(time.Second): + } + }) + } +} diff --git a/oauth2/filetokensource/watch.go b/oauth2/filetokensource/watch.go new file mode 100644 index 0000000..9294c69 --- /dev/null +++ b/oauth2/filetokensource/watch.go @@ -0,0 +1,44 @@ +package filetokensource + +import ( + "time" +) + +const checkFrequency = 50 * time.Millisecond + +func (h *Handler) watchToken() { + ticker := time.NewTicker(h.watchInterval) + defer ticker.Stop() + + // initial fetch + h.fetchToken() + + for { + select { + case <-ticker.C: + h.fetchToken() + case <-h.doneCh: + return + } + } +} + +func (h *Handler) waitForToken() { + ticker := time.NewTicker(checkFrequency) + defer ticker.Stop() + + timeout := time.After(h.waitTimeout) + + for { + select { + case <-ticker.C: + if h.readToken() != nil { + return + } + case <-timeout: + return + case <-h.doneCh: + return + } + } +} diff --git a/oauth2/filetokensource/watch_test.go b/oauth2/filetokensource/watch_test.go new file mode 100644 index 0000000..735fd62 --- /dev/null +++ b/oauth2/filetokensource/watch_test.go @@ -0,0 +1,148 @@ +package filetokensource + +import ( + "errors" + "os" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/metal-toolbox/iam-runtime-contrib/internal/testauth" +) + +func TestWatchToken(t *testing.T) { + authsrv := testauth.NewServer(t) + t.Cleanup(authsrv.Stop) + + type fileToken struct { + subject string + expect string + expectError error + } + + testCases := []struct { + name string + handler *Handler + tokens []fileToken + }{ + { + "no token", + &Handler{ + path: "/tmp/token-not-found", + }, + []fileToken{ + {expectError: os.ErrNotExist}, + }, + }, + { + "valid token", + &Handler{}, + []fileToken{ + {subject: "token1", expect: "token1"}, + }, + }, + { + "multiple tokens", + &Handler{}, + []fileToken{ + {subject: "token1", expect: "token1"}, + {subject: "token1", expect: "token1"}, + {subject: "token2", expect: "token2"}, + }, + }, + { + "bad token skipped", + &Handler{}, + []fileToken{ + {subject: "token1", expect: "token1"}, + {subject: "token1", expect: "token1"}, + {expect: "token1", expectError: jwt.ErrTokenMalformed}, + {subject: "token2", expect: "token2"}, + }, + }, + } + + for _, tc := range testCases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if tc.handler == nil { + tc.handler = &Handler{} + } + + if tc.handler.watchInterval == 0 { + tc.handler.watchInterval = 40 * time.Millisecond + } + + if tc.handler.waitTimeout == 0 { + tc.handler.waitTimeout = time.Second + } + + tc.handler.logger = zap.NewNop() + tc.handler.doneCh = make(chan struct{}, 1) + + defer close(tc.handler.doneCh) + + for i, token := range tc.tokens { + if tc.handler.path == "" { + file, err := os.CreateTemp(t.TempDir(), "token-*") + + require.NoError(t, err, "unexpected error creating temporary token file") + require.NoError(t, file.Close(), "unexpected error closing temporary token file") + + tc.handler.path = file.Name() + + defer func() { + require.NoError(t, os.Remove(tc.handler.path), "unexpected error removing temporary token file") + }() + } + + if i == 0 { + go tc.handler.watchToken() + } + + if !errors.Is(token.expectError, os.ErrNotExist) { + var jwt string + + if token.subject != "" { + jwt = authsrv.TSignSubject(t, token.subject) + } + + err := os.WriteFile(tc.handler.path, []byte(jwt), 0600) + require.NoError(t, err, "unexpected error writing token") + } + + time.Sleep(100 * time.Millisecond) + + oauthToken, err := tc.handler.Token() + + if token.expectError == nil { + assert.NoErrorf(t, err, "token %d: no error expected in handler", i) + } else { + require.Errorf(t, err, "token %d: expected error set in handler", i) + assert.ErrorIsf(t, err, token.expectError, "token %d: unexpected error set in handler", i) + } + + if token.expect != "" { + require.NotNil(t, oauthToken, "expected oauth token to be returned") + + jwt, _, err := jwt.NewParser().ParseUnverified(oauthToken.AccessToken, jwt.MapClaims{}) + require.NoErrorf(t, err, "token %d: unexpected error decoding token", i) + + subject, err := jwt.Claims.GetSubject() + require.NoErrorf(t, err, "token %d: unexpected error returned getting subject", i) + + assert.Equalf(t, token.expect, subject, "token %d: unexpected subject returned", i) + } else { + assert.Empty(t, oauthToken, "unexpected token returned") + } + } + }) + } +} diff --git a/oauth2/iamruntimetokensource/tokensource.go b/oauth2/iamruntimetokensource/tokensource.go new file mode 100644 index 0000000..42a08f5 --- /dev/null +++ b/oauth2/iamruntimetokensource/tokensource.go @@ -0,0 +1,72 @@ +// Package iamruntimetokensource implements oauth2.TokenSource for iam-runtime identity access token. +package iamruntimetokensource + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/identity" + "golang.org/x/oauth2" + + "github.com/metal-toolbox/iam-runtime-contrib/iamruntime" +) + +// TokenSource handles token exchanges by taking an upstream token +// and exchanging it with an token issuer and returning the new token. +type TokenSource struct { + ctx context.Context + runtime identity.IdentityClient + token *oauth2.Token + mu sync.Mutex +} + +// Token requests an access token from the configured runtime. +// Tokens are reused as long as they are valid. +func (s *TokenSource) Token() (*oauth2.Token, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.token.Valid() { + return s.token, nil + } + + resp, err := s.runtime.GetAccessToken(s.ctx, &identity.GetAccessTokenRequest{}) + if err != nil { + return nil, fmt.Errorf("%w: %w", iamruntime.ErrIdentityTokenRequestFailed, err) + } + + token, _, err := jwt.NewParser().ParseUnverified(resp.Token, jwt.MapClaims{}) + if err != nil { + return nil, fmt.Errorf("%w: %w", iamruntime.ErrAccessTokenInvalid, err) + } + + expiry, err := token.Claims.GetExpirationTime() + if err != nil { + return nil, fmt.Errorf("%w: %w", iamruntime.ErrAccessTokenInvalid, err) + } + + var expiryTime time.Time + + if expiry != nil { + expiryTime = expiry.Time + } + + s.token = &oauth2.Token{ + AccessToken: resp.Token, + TokenType: "Bearer", + Expiry: expiryTime, + } + + return s.token, nil +} + +// NewTokenSource creates a new TokenSource using the provided upstream token source and runtime to generate new tokens. +func NewTokenSource(ctx context.Context, runtime identity.IdentityClient) (*TokenSource, error) { + return &TokenSource{ + ctx: ctx, + runtime: runtime, + }, nil +} diff --git a/oauth2/iamruntimetokensource/tokensource_test.go b/oauth2/iamruntimetokensource/tokensource_test.go new file mode 100644 index 0000000..0d4148c --- /dev/null +++ b/oauth2/iamruntimetokensource/tokensource_test.go @@ -0,0 +1,99 @@ +package iamruntimetokensource + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/golang-jwt/jwt/v5" + "github.com/metal-toolbox/iam-runtime/pkg/iam/runtime/identity" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + "google.golang.org/grpc" + + "github.com/metal-toolbox/iam-runtime-contrib/iamruntime" + "github.com/metal-toolbox/iam-runtime-contrib/internal/testauth" + "github.com/metal-toolbox/iam-runtime-contrib/mockruntime" +) + +func TestToken(t *testing.T) { + authsrv := testauth.NewServer(t) + t.Cleanup(authsrv.Stop) + + testCases := []struct { + name string + identityError error + expectError error + }{ + { + "success", + nil, + nil, + }, + { + "failed request", + grpc.ErrServerStopped, + iamruntime.ErrIdentityTokenRequestFailed, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + runtime := new(mockruntime.MockRuntime) + + var req *identity.GetAccessTokenResponse + + if tc.identityError == nil { + req = &identity.GetAccessTokenResponse{ + Token: authsrv.TSignSubject(t, "some subject"), + } + } + + runtime.Mock.On("GetAccessToken").Return(req, tc.identityError) + + ctx := context.Background() + + tokenSource, err := NewTokenSource(ctx, runtime) + require.NoError(t, err, "unexpected error creating new token source") + + token, err := tokenSource.Token() + + if tc.expectError != nil { + require.Error(t, err, "expected error to be returned") + assert.ErrorIs(t, err, tc.expectError, "unexpected error returned") + } else { + assert.NoError(t, err, "expected no error to be returned") + + jwtToken, _, err := jwt.NewParser().ParseUnverified(token.AccessToken, jwt.MapClaims{}) + require.NoError(t, err, "unexpected error parsing jwt token") + + subject, err := jwtToken.Claims.GetSubject() + require.NoError(t, err, "unexpected error getting subject") + + assert.Equal(t, "some subject", subject, "unexpected subject returned") + } + + runtime.Mock.AssertExpectations(t) + }) + } +} + +func ExampleNewTokenSource() { + runtime, _ := iamruntime.NewClient("/tmp/runtime.sock") + + ctx := context.TODO() + + iamtoken, _ := NewTokenSource(ctx, runtime) + + httpClient := oauth2.NewClient(ctx, iamtoken) + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://iam.example.com/resource/explten-abc123", nil) + + resp, _ := httpClient.Do(req) + + resp.Body.Close() + + fmt.Println("Status Code:", resp.StatusCode) +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..44119af --- /dev/null +++ b/renovate.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base" + ], + "packageRules": [ + { + "matchUpdateTypes": ["minor", "patch", "pin", "digest"], + "automerge": true + } + ], + "postUpdateOptions": [ + "gomodTidy" + ] +}