Compare commits
5 commits
Author | SHA1 | Date | |
---|---|---|---|
|
55acd0013a | ||
|
a64b97dd5a | ||
|
95ffcb5bdc | ||
|
776115080d | ||
|
eb0e9a1b7a |
178 changed files with 3860 additions and 19054 deletions
|
@ -1,57 +0,0 @@
|
|||
name: Bug Report
|
||||
description: "Create a bug report to help us improve ZITADEL. Click [here](https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md#product-management) to see how we process your issue."
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
type: Bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
- type: checkboxes
|
||||
id: preflight
|
||||
attributes:
|
||||
label: Preflight Checklist
|
||||
options:
|
||||
- label:
|
||||
I could not find a solution in the documentation, the existing issues or discussions
|
||||
required: true
|
||||
- label:
|
||||
I have joined the [ZITADEL chat](https://zitadel.com/chat)
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: Which version of the OIDC library are you using.
|
||||
- type: textarea
|
||||
id: impact
|
||||
attributes:
|
||||
label: Describe the problem caused by this bug
|
||||
description: A clear and concise description of the problem you have and what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: To reproduce
|
||||
description: Steps to reproduce the behaviour
|
||||
placeholder: |
|
||||
Steps to reproduce the behavior:
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, add screenshots to help explain your problem.
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
placeholder: As a [type of user], I want [some goal] so that [some reason].
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Please add any other infos that could be useful.
|
|
@ -1,31 +0,0 @@
|
|||
name: 📄 Documentation
|
||||
description: Create an issue for missing or wrong documentation.
|
||||
labels: ["docs"]
|
||||
type: task
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this issue.
|
||||
- type: checkboxes
|
||||
id: preflight
|
||||
attributes:
|
||||
label: Preflight Checklist
|
||||
options:
|
||||
- label:
|
||||
I could not find a solution in the existing issues, docs, nor discussions
|
||||
required: true
|
||||
- label:
|
||||
I have joined the [ZITADEL chat](https://zitadel.com/chat)
|
||||
- type: textarea
|
||||
id: docs
|
||||
attributes:
|
||||
label: Describe the docs your are missing or that are wrong
|
||||
placeholder: As a [type of user], I want [some goal] so that [some reason].
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Please add any other infos that could be useful.
|
|
@ -1,55 +0,0 @@
|
|||
name: 🛠️ Improvement
|
||||
description: "Create an new issue for an improvment in ZITADEL"
|
||||
labels: ["enhancement"]
|
||||
type: enhancement
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this proposal / feature reqeust
|
||||
- type: checkboxes
|
||||
id: preflight
|
||||
attributes:
|
||||
label: Preflight Checklist
|
||||
options:
|
||||
- label:
|
||||
I could not find a solution in the existing issues, docs, nor discussions
|
||||
required: true
|
||||
- label:
|
||||
I have joined the [ZITADEL chat](https://zitadel.com/chat)
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Describe your problem
|
||||
description: Please describe your problem this improvement is supposed to solve.
|
||||
placeholder: Describe the problem you have
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Describe your ideal solution
|
||||
description: Which solution do you propose?
|
||||
placeholder: As a [type of user], I want [some goal] so that [some reason].
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: Which version of the OIDC Library are you using.
|
||||
- type: dropdown
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: How do you use ZITADEL?
|
||||
options:
|
||||
- ZITADEL Cloud
|
||||
- Self-hosted
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Please add any other infos that could be useful.
|
|
@ -1,16 +0,0 @@
|
|||
### Definition of Ready
|
||||
|
||||
- [ ] I am happy with the code
|
||||
- [ ] Short description of the feature/issue is added in the pr description
|
||||
- [ ] PR is linked to the corresponding user story
|
||||
- [ ] Acceptance criteria are met
|
||||
- [ ] All open todos and follow ups are defined in a new ticket and justified
|
||||
- [ ] Deviations from the acceptance criteria and design are agreed with the PO and documented.
|
||||
- [ ] No debug or dead code
|
||||
- [ ] My code has no repetitions
|
||||
- [ ] Critical parts are tested automatically
|
||||
- [ ] Where possible E2E tests are implemented
|
||||
- [ ] Documentation/examples are up-to-date
|
||||
- [ ] All non-functional requirements are met
|
||||
- [ ] Functionality of the acceptance criteria is checked manually on the dev system.
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
name: Add new issues to product management project
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
|
||||
jobs:
|
||||
add-to-project:
|
||||
name: Add issue and community pr to project
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: add issue
|
||||
uses: actions/add-to-project@v1.0.2
|
||||
if: ${{ github.event_name == 'issues' }}
|
||||
with:
|
||||
# You can target a repository in a different organization
|
||||
# to the issue
|
||||
project-url: https://github.com/orgs/zitadel/projects/2
|
||||
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
||||
- uses: tspascoal/get-user-teams-membership@v3
|
||||
id: checkUserMember
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
with:
|
||||
username: ${{ github.actor }}
|
||||
GITHUB_TOKEN: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
||||
- name: add pr
|
||||
uses: actions/add-to-project@v1.0.2
|
||||
if: ${{ github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]' && !contains(steps.checkUserMember.outputs.teams, 'engineers')}}
|
||||
with:
|
||||
# You can target a repository in a different organization
|
||||
# to the issue
|
||||
project-url: https://github.com/orgs/zitadel/projects/2
|
||||
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
||||
- uses: actions-ecosystem/action-add-labels@v1.1.3
|
||||
if: ${{ github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]' && !contains(steps.checkUserMember.outputs.teams, 'staff')}}
|
||||
with:
|
||||
github_token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
||||
labels: |
|
||||
os-contribution
|
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
name: 🐛 Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
name: 🚀 Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
|
@ -9,16 +9,6 @@ updates:
|
|||
commit-message:
|
||||
prefix: chore
|
||||
include: scope
|
||||
- package-ecosystem: gomod
|
||||
target-branch: "2.12.x"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: '04:00'
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: chore
|
||||
include: scope
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
|
@ -2,10 +2,10 @@ name: "Code scanning - action"
|
|||
|
||||
on:
|
||||
push:
|
||||
branches: [main,next]
|
||||
branches: [main, ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [main,next]
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 11 * * 0'
|
||||
|
||||
|
@ -16,7 +16,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
|
@ -29,7 +29,7 @@ jobs:
|
|||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v2
|
||||
# Override language selection by uncommenting this and choosing your languages
|
||||
with:
|
||||
languages: go
|
||||
|
@ -37,7 +37,7 @@ jobs:
|
|||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
@ -51,4 +51,4 @@ jobs:
|
|||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v2
|
18
.github/workflows/issue.yml
vendored
Normal file
18
.github/workflows/issue.yml
vendored
Normal file
|
@ -0,0 +1,18 @@
|
|||
name: Add new issues to product management project
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
|
||||
jobs:
|
||||
add-to-project:
|
||||
name: Add issue to project
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/add-to-project@v0.4.1
|
||||
with:
|
||||
# You can target a repository in a different organization
|
||||
# to the issue
|
||||
project-url: https://github.com/orgs/zitadel/projects/2
|
||||
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
|
@ -2,9 +2,8 @@ name: Release
|
|||
on:
|
||||
push:
|
||||
branches:
|
||||
- "2.11.x"
|
||||
- main
|
||||
- next
|
||||
- 1.13.x
|
||||
tags-ignore:
|
||||
- '**'
|
||||
pull_request:
|
||||
|
@ -14,34 +13,33 @@ on:
|
|||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
go: ['1.23', '1.24']
|
||||
go: ['1.18', '1.19', '1.20']
|
||||
name: Go ${{ matrix.go }} test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
- run: go test -race -v -coverprofile=profile.cov -coverpkg=./pkg/... ./pkg/...
|
||||
- uses: codecov/codecov-action@v5.4.3
|
||||
- run: go test -race -v -coverprofile=profile.cov -coverpkg=github.com/zitadel/oidc/... ./pkg/...
|
||||
- uses: codecov/codecov-action@v3.1.1
|
||||
with:
|
||||
file: ./profile.cov
|
||||
name: codecov-go
|
||||
release:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [test]
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next' }}
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/1.13.x' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Source checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v3
|
||||
- name: Semantic Release
|
||||
uses: cycjimmy/semantic-release-action@v4
|
||||
uses: cycjimmy/semantic-release-action@v3
|
||||
with:
|
||||
dry_run: false
|
||||
semantic_version: 18.0.1
|
|
@ -1,9 +1,5 @@
|
|||
module.exports = {
|
||||
branches: [
|
||||
{name: "2.11.x"},
|
||||
{name: "main"},
|
||||
{name: "next", prerelease: true},
|
||||
],
|
||||
branches: ["1.13.x", "main"],
|
||||
plugins: [
|
||||
"@semantic-release/commit-analyzer",
|
||||
"@semantic-release/release-notes-generator",
|
||||
|
|
7
NEXT_RELEASE.md
Normal file
7
NEXT_RELEASE.md
Normal file
|
@ -0,0 +1,7 @@
|
|||
|
||||
# Backwards-incompatible changes to be made in the next major release
|
||||
|
||||
- Add `rp/RelyingParty.GetRevokeEndpoint`
|
||||
- Rename `op/OpStorage.GetKeyByIDAndUserID` to `op/OpStorage.GetKeyByIDAndClientID`
|
||||
- Add `CanRefreshTokenInfo` (`GetRefreshTokenInfo()`) to `op.Storage`
|
||||
|
111
README.md
111
README.md
|
@ -2,13 +2,13 @@
|
|||
|
||||
[](https://github.com/semantic-release/semantic-release)
|
||||
[](https://github.com/zitadel/oidc/actions)
|
||||
[](https://pkg.go.dev/github.com/zitadel/oidc/v3)
|
||||
[](https://pkg.go.dev/github.com/zitadel/oidc)
|
||||
[](https://github.com/zitadel/oidc/blob/master/LICENSE)
|
||||
[](https://github.com/zitadel/oidc/releases)
|
||||
[](https://goreportcard.com/report/github.com/zitadel/oidc/v3)
|
||||
[](https://goreportcard.com/report/github.com/zitadel/oidc)
|
||||
[](https://codecov.io/gh/zitadel/oidc)
|
||||
|
||||
[](https://openid.net/certification/)
|
||||

|
||||
|
||||
## What Is It
|
||||
|
||||
|
@ -21,7 +21,6 @@ Whenever possible we tried to reuse / extend existing packages like `OAuth2 for
|
|||
## Basic Overview
|
||||
|
||||
The most important packages of the library:
|
||||
|
||||
<pre>
|
||||
/pkg
|
||||
/client clients using the OP for retrieving, exchanging and verifying tokens
|
||||
|
@ -35,13 +34,9 @@ The most important packages of the library:
|
|||
/client/app web app / RP demonstrating authorization code flow using various authentication methods (code, PKCE, JWT profile)
|
||||
/client/github example of the extended OAuth2 library, providing an HTTP client with a reuse token source
|
||||
/client/service demonstration of JWT Profile Authorization Grant
|
||||
/server examples of an OpenID Provider implementations (including dynamic) with some very basic login UI
|
||||
/server example of an OpenID Provider implementation including some very basic login UI
|
||||
</pre>
|
||||
|
||||
### Semver
|
||||
|
||||
This package uses [semver](https://semver.org/) for [releases](https://github.com/zitadel/oidc/releases). Major releases ship breaking changes. Starting with the `v2` to `v3` increment we provide an [upgrade guide](UPGRADING.md) to ease migration to a newer version.
|
||||
|
||||
## How To Use It
|
||||
|
||||
Check the `/example` folder where example code for different scenarios is located.
|
||||
|
@ -49,90 +44,22 @@ Check the `/example` folder where example code for different scenarios is locate
|
|||
```bash
|
||||
# start oidc op server
|
||||
# oidc discovery http://localhost:9998/.well-known/openid-configuration
|
||||
go run github.com/zitadel/oidc/v3/example/server
|
||||
go run github.com/zitadel/oidc/example/server
|
||||
# start oidc web client (in a new terminal)
|
||||
CLIENT_ID=web CLIENT_SECRET=secret ISSUER=http://localhost:9998/ SCOPES="openid profile" PORT=9999 go run github.com/zitadel/oidc/v3/example/client/app
|
||||
CLIENT_ID=web CLIENT_SECRET=secret ISSUER=http://localhost:9998 SCOPES="openid profile" PORT=9999 go run github.com/zitadel/oidc/example/client/app
|
||||
```
|
||||
|
||||
- open http://localhost:9999/login in your browser
|
||||
- you will be redirected to op server and the login UI
|
||||
- login with user `test-user@localhost` and password `verysecure`
|
||||
- login with user `test-user` and password `verysecure`
|
||||
- the OP will redirect you to the client app, which displays the user info
|
||||
|
||||
for the dynamic issuer, just start it with:
|
||||
|
||||
```bash
|
||||
go run github.com/zitadel/oidc/v3/example/server/dynamic
|
||||
```
|
||||
|
||||
the oidc web client above will still work, but if you add `oidc.local` (pointing to 127.0.0.1) in your hosts file you can also start it with:
|
||||
|
||||
```bash
|
||||
CLIENT_ID=web CLIENT_SECRET=secret ISSUER=http://oidc.local:9998/ SCOPES="openid profile" PORT=9999 go run github.com/zitadel/oidc/v3/example/client/app
|
||||
```
|
||||
|
||||
> Note: Usernames are suffixed with the hostname (`test-user@localhost` or `test-user@oidc.local`)
|
||||
|
||||
### Server configuration
|
||||
|
||||
Example server allows extra configuration using environment variables and could be used for end to
|
||||
end testing of your services.
|
||||
|
||||
| Name | Format | Description |
|
||||
| ------------ | -------------------------------- | ------------------------------------- |
|
||||
| PORT | Number between 1 and 65535 | OIDC listen port |
|
||||
| REDIRECT_URI | Comma-separated URIs | List of allowed redirect URIs |
|
||||
| USERS_FILE | Path to json in local filesystem | Users with their data and credentials |
|
||||
|
||||
Here is json equivalent for one of the default users
|
||||
|
||||
```json
|
||||
{
|
||||
"id2": {
|
||||
"ID": "id2",
|
||||
"Username": "test-user2",
|
||||
"Password": "verysecure",
|
||||
"FirstName": "Test",
|
||||
"LastName": "User2",
|
||||
"Email": "test-user2@zitadel.ch",
|
||||
"EmailVerified": true,
|
||||
"Phone": "",
|
||||
"PhoneVerified": false,
|
||||
"PreferredLanguage": "DE",
|
||||
"IsAdmin": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
| | Relying party | OpenID Provider | Specification |
|
||||
| -------------------- | ------------- | --------------- | -------------------------------------------- |
|
||||
| Code Flow | yes | yes | OpenID Connect Core 1.0, [Section 3.1][1] |
|
||||
| Implicit Flow | no[^1] | yes | OpenID Connect Core 1.0, [Section 3.2][2] |
|
||||
| Hybrid Flow | no | not yet | OpenID Connect Core 1.0, [Section 3.3][3] |
|
||||
| Client Credentials | yes | yes | OpenID Connect Core 1.0, [Section 9][4] |
|
||||
| Refresh Token | yes | yes | OpenID Connect Core 1.0, [Section 12][5] |
|
||||
| Discovery | yes | yes | OpenID Connect [Discovery][6] 1.0 |
|
||||
| JWT Profile | yes | yes | [RFC 7523][7] |
|
||||
| PKCE | yes | yes | [RFC 7636][8] |
|
||||
| Token Exchange | yes | yes | [RFC 8693][9] |
|
||||
| Device Authorization | yes | yes | [RFC 8628][10] |
|
||||
| mTLS | not yet | not yet | [RFC 8705][11] |
|
||||
| Back-Channel Logout | not yet | yes | OpenID Connect [Back-Channel Logout][12] 1.0 |
|
||||
|
||||
[1]: https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth "3.1. Authentication using the Authorization Code Flow"
|
||||
[2]: https://openid.net/specs/openid-connect-core-1_0.html#ImplicitFlowAuth "3.2. Authentication using the Implicit Flow"
|
||||
[3]: https://openid.net/specs/openid-connect-core-1_0.html#HybridFlowAuth "3.3. Authentication using the Hybrid Flow"
|
||||
[4]: https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication "9. Client Authentication"
|
||||
[5]: https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokens "12. Using Refresh Tokens"
|
||||
[6]: https://openid.net/specs/openid-connect-discovery-1_0.html "OpenID Connect Discovery 1.0 incorporating errata set 1"
|
||||
[7]: https://www.rfc-editor.org/rfc/rfc7523.html "JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants"
|
||||
[8]: https://www.rfc-editor.org/rfc/rfc7636.html "Proof Key for Code Exchange by OAuth Public Clients"
|
||||
[9]: https://www.rfc-editor.org/rfc/rfc8693.html "OAuth 2.0 Token Exchange"
|
||||
[10]: https://www.rfc-editor.org/rfc/rfc8628.html "OAuth 2.0 Device Authorization Grant"
|
||||
[11]: https://www.rfc-editor.org/rfc/rfc8705.html "OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens"
|
||||
[12]: https://openid.net/specs/openid-connect-backchannel-1_0.html "OpenID Connect Back-Channel Logout 1.0 incorporating errata set 1"
|
||||
| | Code Flow | Implicit Flow | Hybrid Flow | Discovery | PKCE | Token Exchange | mTLS | JWT Profile | Refresh Token | Client Credentials |
|
||||
|------------------|-----------|---------------|-------------|-----------|------|----------------|---------|-------------|---------------|--------------------|
|
||||
| Relying Party | yes | no[^1] | no | yes | yes | partial | not yet | yes | yes | not yet |
|
||||
| OpenID Provider | yes | yes | not yet | yes | yes | not yet | not yet | yes | yes | yes |
|
||||
|
||||
## Contributors
|
||||
|
||||
|
@ -144,9 +71,13 @@ Made with [contrib.rocks](https://contrib.rocks).
|
|||
|
||||
### Resources
|
||||
|
||||
For your convenience you can find the relevant guides linked below.
|
||||
For your convenience you can find the relevant standards linked below.
|
||||
|
||||
- [OpenID Connect Core 1.0 incorporating errata set 1](https://openid.net/specs/openid-connect-core-1_0.html)
|
||||
- [Proof Key for Code Exchange by OAuth Public Clients](https://tools.ietf.org/html/rfc7636)
|
||||
- [OAuth 2.0 Token Exchange](https://tools.ietf.org/html/draft-ietf-oauth-token-exchange-19)
|
||||
- [OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens](https://tools.ietf.org/html/draft-ietf-oauth-mtls-17)
|
||||
- [JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants](https://tools.ietf.org/html/rfc7523)
|
||||
- [OIDC/OAuth Flow in Zitadel (using this library)](https://zitadel.com/docs/guides/integrate/login-users)
|
||||
|
||||
## Supported Go Versions
|
||||
|
@ -155,10 +86,11 @@ For security reasons, we only support and recommend the use of one of the latest
|
|||
Versions that also build are marked with :warning:.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| <1.23 | :x: |
|
||||
| 1.23 | :white_check_mark: |
|
||||
| 1.24 | :white_check_mark: |
|
||||
|---------|--------------------|
|
||||
| <1.18 | :x: |
|
||||
| 1.18 | :warning: |
|
||||
| 1.19 | :white_check_mark: |
|
||||
| 1.20 | :white_check_mark: |
|
||||
|
||||
## Why another library
|
||||
|
||||
|
@ -189,4 +121,5 @@ Unless required by applicable law or agreed to in writing, software distributed
|
|||
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.
|
||||
|
||||
|
||||
[^1]: https://github.com/zitadel/oidc/issues/135#issuecomment-950563892
|
||||
|
|
49
SECURITY.md
49
SECURITY.md
|
@ -1,20 +1,43 @@
|
|||
# Security Policy
|
||||
|
||||
Please refer to the security policy [on zitadel/zitadel](https://github.com/zitadel/zitadel/blob/main/SECURITY.md) which is applicable for all open source repositories of our organization.
|
||||
At ZITADEL we are extremely grateful for security aware people that disclose vulnerabilities to us and the open source community. All reports will be investigated by our team.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We currently support the following version of the OIDC framework:
|
||||
After the initial Release the following version support will apply
|
||||
|
||||
| Version | Supported | Branch | Details |
|
||||
| -------- | ------------------ | ----------- | ------------------------------------ |
|
||||
| 0.x.x | :x: | | not maintained |
|
||||
| <2.11 | :x: | | not maintained |
|
||||
| 2.11.x | :lock: :warning: | [2.11.x][1] | security only, [community effort][2] |
|
||||
| 3.x.x | :heavy_check_mark: | [main][3] | supported |
|
||||
| 4.0.0-xx | :white_check_mark: | [next][4] | [development branch] |
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 0.x.x | :x: |
|
||||
| 1.x.x | :white_check_mark: |
|
||||
| 2.x.x | :white_check_mark: (not released) |
|
||||
|
||||
[1]: https://github.com/zitadel/oidc/tree/2.11.x
|
||||
[2]: https://github.com/zitadel/oidc/discussions/458
|
||||
[3]: https://github.com/zitadel/oidc/tree/main
|
||||
[4]: https://github.com/zitadel/oidc/tree/next
|
||||
## Reporting a vulnerability
|
||||
|
||||
To file a incident, please disclose by email to security@zitadel.com with the security details.
|
||||
|
||||
At the moment GPG encryption is no yet supported, however you may sign your message at will.
|
||||
|
||||
### When should I report a vulnerability
|
||||
|
||||
* You think you discovered a ...
|
||||
* ... potential security vulnerability in the SDK
|
||||
* ... vulnerability in another project that this SDK bases on
|
||||
* For projects with their own vulnerability reporting and disclosure process, please report it directly there
|
||||
|
||||
### When should I NOT report a vulnerability
|
||||
|
||||
* You need help applying security related updates
|
||||
* Your issue is not security related
|
||||
|
||||
## Security Vulnerability Response
|
||||
|
||||
TBD
|
||||
|
||||
## Public Disclosure
|
||||
|
||||
All accepted and mitigated vulnerabilities will be published on the [Github Security Page](https://github.com/zitadel/oidc/security/advisories)
|
||||
|
||||
### Timing
|
||||
|
||||
We think it is crucial to publish advisories `ASAP` as mitigations are ready. But due to the unknown nature of the disclosures the time frame can range from 7 to 90 days.
|
||||
|
|
370
UPGRADING.md
370
UPGRADING.md
|
@ -1,370 +0,0 @@
|
|||
# Upgrading
|
||||
|
||||
All commands are executed from the root of the project that imports oidc packages.
|
||||
`sed` commands are created with **GNU sed** in mind and might need alternate syntax
|
||||
on non-GNU systems, such as MacOS.
|
||||
Alternatively, GNU sed can be installed on such systems. (`coreutils` package?).
|
||||
|
||||
## V2 to V3
|
||||
|
||||
**TL;DR** at the [bottom](#full-script) of this chapter is a full `sed` script
|
||||
containing all automatic steps at once.
|
||||
|
||||
|
||||
As first steps we will:
|
||||
1. Download the latest v3 module;
|
||||
2. Replace imports in all Go files;
|
||||
3. Tidy the module file;
|
||||
|
||||
```bash
|
||||
go get -u github.com/zitadel/oidc/v3
|
||||
find . -type f -name '*.go' | xargs sed -i \
|
||||
-e 's/github\.com\/zitadel\/oidc\/v2/github.com\/zitadel\/oidc\/v3/g'
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
### global
|
||||
|
||||
#### go-jose package
|
||||
|
||||
`gopkg.in/square/go-jose.v2` import has been changed to `github.com/go-jose/go-jose/v3`.
|
||||
That means that the imported types are also changed and imports need to be adapted.
|
||||
|
||||
```bash
|
||||
find . -type f -name '*.go' | xargs sed -i \
|
||||
-e 's/gopkg.in\/square\/go-jose\.v2/github.com\/go-jose\/go-jose\/v3/g'
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
### op
|
||||
|
||||
```go
|
||||
import "github.com/zitadel/oidc/v3/pkg/op"
|
||||
```
|
||||
|
||||
#### Logger
|
||||
|
||||
This version of OIDC adds logging to the framework. For this we use the new Go standard library `log/slog`. (Until v3.12.0 we used `x/exp/slog`).
|
||||
Mostly OIDC will use error level logs where it's returning an error through a HTTP handler. OIDC errors that are user facing don't carry much context, also for security reasons. With logging we are now able to print the error context, so that developers can more easily find the source of their issues. Previously we just discarded such context.
|
||||
|
||||
Most users of the OP package with the storage interface will not experience breaking changes. However if you use `RequestError()` directly in your code, you now need to give it a `Logger` as final argument.
|
||||
|
||||
The `OpenIDProvider` and sub-interfaces like `Authorizer` and `Exchanger` got a `Logger()` method to return the configured logger. This logger is in turn used by `AuthRequestError()`. You configure the logger with the `WithLogger()` for the `Provider`. By default the `slog.Default()` is used.
|
||||
|
||||
We also provide a new optional interface: [`LogAuthRequest`](https://pkg.go.dev/github.com/zitadel/oidc/v3/pkg/op#LogAuthRequest). If an `AuthRequest` implements this interface, it is completely passed into the logger after an error. Its `LogValue()` will be used by `slog` to print desired fields. This allows omitting sensitive fields you wish not no print. If the interface is not implemented, no `AuthRequest` details will ever be printed.
|
||||
|
||||
#### Server interface
|
||||
|
||||
We've added a new [`Server`](https://pkg.go.dev/github.com/zitadel/oidc/v3/pkg/op#Server) interface. This interface is experimental and subject to change. See [issue 440](https://github.com/zitadel/oidc/issues/440) for the motivation and discussion around this new interface.
|
||||
Usage of the new interface is not required, but may be used for advanced scenarios when working with the `Storage` interface isn't the optimal solution for your app (like we experienced in [Zitadel](https://github.com/zitadel/zitadel)).
|
||||
|
||||
#### AuthRequestError
|
||||
|
||||
`AuthRequestError` now takes the complete `Authorizer` as final argument, instead of only the encoder.
|
||||
This is to facilitate the use of the `Logger` as described above.
|
||||
|
||||
```bash
|
||||
find . -type f -name '*.go' | xargs sed -i \
|
||||
-e 's/\bAuthRequestError(w, r, authReq, err, authorizer.Encoder())/AuthRequestError(w, r, authReq, err, authorizer)/g'
|
||||
```
|
||||
|
||||
Note: the sed regex might not find all uses if the local variables of the passed arguments use different names.
|
||||
|
||||
#### AccessTokenVerifier
|
||||
|
||||
`AccessTokenVerifier` interface has become a struct type. `NewAccessTokenVerifier` now returns a pointer to `AccessTokenVerifier`.
|
||||
Variable and struct fields declarations need to be changed from `op.AccessTokenVerifier` to `*op.AccessTokenVerifier`.
|
||||
|
||||
```bash
|
||||
find . -type f -name '*.go' | xargs sed -i \
|
||||
-e 's/\bop\.AccessTokenVerifier\b/*op.AccessTokenVerifier/g'
|
||||
```
|
||||
|
||||
#### JWTProfileVerifier
|
||||
|
||||
`JWTProfileVerifier` interface has become a struct type. `NewJWTProfileVerifier` now returns a pointer to `JWTProfileVerifier`.
|
||||
Variable and struct fields declarations need to be changed from `op.JWTProfileVerifier` to `*op.JWTProfileVerifier`.
|
||||
|
||||
```bash
|
||||
find . -type f -name '*.go' | xargs sed -i \
|
||||
-e 's/\bop\.JWTProfileVerifier\b/*op.JWTProfileVerifier/g'
|
||||
```
|
||||
|
||||
#### IDTokenHintVerifier
|
||||
|
||||
`IDTokenHintVerifier` interface has become a struct type. `NewIDTokenHintVerifier` now returns a pointer to `IDTokenHintVerifier`.
|
||||
Variable and struct fields declarations need to be changed from `op.IDTokenHintVerifier` to `*op.IDTokenHintVerifier`.
|
||||
|
||||
```bash
|
||||
find . -type f -name '*.go' | xargs sed -i \
|
||||
-e 's/\bop\.IDTokenHintVerifier\b/*op.IDTokenHintVerifier/g'
|
||||
```
|
||||
|
||||
#### ParseRequestObject
|
||||
|
||||
`ParseRequestObject` no longer returns `*oidc.AuthRequest` as it already operates on the pointer for the passed `authReq` argument. As such the argument and the return value were the same pointer. Callers can just use the original `*oidc.AuthRequest` now.
|
||||
|
||||
#### Endpoint Configuration
|
||||
|
||||
`Endpoint`s returned from `Configuration` interface methods are now pointers. Usually, `op.Provider` is the main implementation of the `Configuration` interface. However, if a custom implementation is used, you should be able to update it using the following:
|
||||
|
||||
```bash
|
||||
find . -type f -name '*.go' | xargs sed -i \
|
||||
-e 's/AuthorizationEndpoint() Endpoint/AuthorizationEndpoint() *Endpoint/g' \
|
||||
-e 's/TokenEndpoint() Endpoint/TokenEndpoint() *Endpoint/g' \
|
||||
-e 's/IntrospectionEndpoint() Endpoint/IntrospectionEndpoint() *Endpoint/g' \
|
||||
-e 's/UserinfoEndpoint() Endpoint/UserinfoEndpoint() *Endpoint/g' \
|
||||
-e 's/RevocationEndpoint() Endpoint/RevocationEndpoint() *Endpoint/g' \
|
||||
-e 's/EndSessionEndpoint() Endpoint/EndSessionEndpoint() *Endpoint/g' \
|
||||
-e 's/KeysEndpoint() Endpoint/KeysEndpoint() *Endpoint/g' \
|
||||
-e 's/DeviceAuthorizationEndpoint() Endpoint/DeviceAuthorizationEndpoint() *Endpoint/g'
|
||||
```
|
||||
|
||||
#### CreateDiscoveryConfig
|
||||
|
||||
`CreateDiscoveryConfig` now takes a context as first argument. The following adds `context.TODO()` to the function:
|
||||
|
||||
```bash
|
||||
find . -type f -name '*.go' | xargs sed -i \
|
||||
-e 's/op\.CreateDiscoveryConfig(/op.CreateDiscoveryConfig(context.TODO(), /g'
|
||||
```
|
||||
|
||||
It now takes the issuer out of the context using the [`IssuerFromContext`](https://pkg.go.dev/github.com/zitadel/oidc/v3/pkg/op#IssuerFromContext) functionality,
|
||||
instead of the `config.IssuerFromRequest()` method.
|
||||
|
||||
#### CreateRouter
|
||||
|
||||
`CreateRouter` now returns a `chi.Router` instead of `*mux.Router`.
|
||||
Usually this function is called when the Provider is constructed and not by package consumers.
|
||||
However if your project does call this function directly, manual update of the code is required.
|
||||
|
||||
#### DeviceAuthorizationStorage
|
||||
|
||||
`DeviceAuthorizationStorage` dropped the following methods:
|
||||
|
||||
- `GetDeviceAuthorizationByUserCode`
|
||||
- `CompleteDeviceAuthorization`
|
||||
- `DenyDeviceAuthorization`
|
||||
|
||||
These methods proved not to be required from a library point of view.
|
||||
Implementations of a device authorization flow may take care of these calls in a way they see fit.
|
||||
|
||||
#### AuthorizeCodeChallenge
|
||||
|
||||
The `AuthorizeCodeChallenge` function now only takes the `CodeVerifier` argument, instead of the complete `*oidc.AccessTokenRequest`.
|
||||
|
||||
```bash
|
||||
find . -type f -name '*.go' | xargs sed -i \
|
||||
-e 's/op\.AuthorizeCodeChallenge(tokenReq/op.AuthorizeCodeChallenge(tokenReq.CodeVerifier/g'
|
||||
```
|
||||
|
||||
### client
|
||||
|
||||
```go
|
||||
import "github.com/zitadel/oidc/v3/pkg/client"
|
||||
```
|
||||
|
||||
#### Context
|
||||
|
||||
All client calls now take a context as first argument. The following adds `context.TODO()` to all the affected functions:
|
||||
|
||||
```bash
|
||||
find . -type f -name '*.go' | xargs sed -i \
|
||||
-e 's/client\.Discover(/client.Discover(context.TODO(), /g' \
|
||||
-e 's/client\.CallTokenEndpoint(/client.CallTokenEndpoint(context.TODO(), /g' \
|
||||
-e 's/client\.CallEndSessionEndpoint(/client.CallEndSessionEndpoint(context.TODO(), /g' \
|
||||
-e 's/client\.CallRevokeEndpoint(/client.CallRevokeEndpoint(context.TODO(), /g' \
|
||||
-e 's/client\.CallTokenExchangeEndpoint(/client.CallTokenExchangeEndpoint(context.TODO(), /g' \
|
||||
-e 's/client\.CallDeviceAuthorizationEndpoint(/client.CallDeviceAuthorizationEndpoint(context.TODO(), /g' \
|
||||
-e 's/client\.JWTProfileExchange(/client.JWTProfileExchange(context.TODO(), /g'
|
||||
```
|
||||
|
||||
#### keyFile type
|
||||
|
||||
The `keyFile` struct type is now exported a `KeyFile` and returned by the `ConfigFromKeyFile` and `ConfigFromKeyFileData`. No changes are needed on the caller's side.
|
||||
|
||||
### client/profile
|
||||
|
||||
The package now defines a new interface `TokenSource` which compliments the `oauth2.TokenSource` with a `TokenCtx` method, so that a context can be explicitly added on each call. Users can migrate to the new method when they whish.
|
||||
|
||||
`NewJWTProfileTokenSource` now takes a context as first argument, so do the related `NewJWTProfileTokenSourceFromKeyFile` and `NewJWTProfileTokenSourceFromKeyFileData`. The context is used for the Discovery request.
|
||||
|
||||
```bash
|
||||
find . -type f -name '*.go' | xargs sed -i \
|
||||
-e 's/profile\.NewJWTProfileTokenSource(/profile.NewJWTProfileTokenSource(context.TODO(), /g' \
|
||||
-e 's/profile\.NewJWTProfileTokenSourceFromKeyFileData(/profile.NewJWTProfileTokenSourceFromKeyFileData(context.TODO(), /g' \
|
||||
-e 's/profile\.NewJWTProfileTokenSourceFromKeyFile(/profile.NewJWTProfileTokenSourceFromKeyFile(context.TODO(), /g'
|
||||
```
|
||||
|
||||
|
||||
### client/rp
|
||||
|
||||
```go
|
||||
import "github.com/zitadel/oidc/v3/pkg/client/rs"
|
||||
```
|
||||
|
||||
#### Discover
|
||||
|
||||
The `Discover` function has been removed. Use `client.Discover` instead.
|
||||
|
||||
#### Context
|
||||
|
||||
Most `rp` functions now require a context as first argument. The following adds `context.TODO()` to the function that have no additional changes. Functions with more complex changes are documented below.
|
||||
|
||||
```bash
|
||||
find . -type f -name '*.go' | xargs sed -i \
|
||||
-e 's/rp\.NewRelyingPartyOIDC(/rp.NewRelyingPartyOIDC(context.TODO(), /g' \
|
||||
-e 's/rp\.EndSession(/rp.EndSession(context.TODO(), /g' \
|
||||
-e 's/rp\.RevokeToken(/rp.RevokeToken(context.TODO(), /g' \
|
||||
-e 's/rp\.DeviceAuthorization(/rp.DeviceAuthorization(context.TODO(), /g'
|
||||
```
|
||||
|
||||
Remember to replace `context.TODO()` with a context that is applicable for your app, where possible.
|
||||
|
||||
#### RefreshAccessToken
|
||||
|
||||
1. Renamed to `RefreshTokens`;
|
||||
2. A context must be passed;
|
||||
3. An `*oidc.Tokens` object is now returned, which included an ID Token if it was returned by the server;
|
||||
4. The function is now generic and requires a type argument for the `IDTokenClaims` implementation inside the returned `oidc.Tokens` object;
|
||||
|
||||
For most use cases `*oidc.IDTokenClaims` can be used as type argument. A custom implementation of `oidc.IDClaims` can be used if type-safe access to custom claims is required.
|
||||
|
||||
```bash
|
||||
find . -type f -name '*.go' | xargs sed -i \
|
||||
-e 's/rp\.RefreshAccessToken(/rp.RefreshTokens[*oidc.IDTokenClaims](context.TODO(), /g'
|
||||
```
|
||||
|
||||
Users that called `tokens.Extra("id_token").(string)` and a subsequent `VerifyTokens` to get the claims, no longer need to do this. The ID token is verified (when present) by `RefreshTokens` already.
|
||||
|
||||
|
||||
#### Userinfo
|
||||
|
||||
1. A context must be passed as first argument;
|
||||
2. The function is now generic and requires a type argument for the returned user info object;
|
||||
|
||||
For most use cases `*oidc.UserInfo` can be used a type argument. A [custom implementation](https://pkg.go.dev/github.com/zitadel/oidc/v3/pkg/client/rp#example-Userinfo-Custom) of `rp.SubjectGetter` can be used if type-safe access to custom claims is required.
|
||||
|
||||
```bash
|
||||
find . -type f -name '*.go' | xargs sed -i \
|
||||
-e 's/rp\.Userinfo(/rp.Userinfo[*oidc.UserInfo](context.TODO(), /g'
|
||||
```
|
||||
|
||||
#### UserinfoCallback
|
||||
|
||||
`UserinfoCallback` has an additional type argument fot the `UserInfo` object. Typically the type argument can be inferred by the compiler, by the function that is passed. The actual code update cannot be done by a simple `sed` script and depends on how the caller implemented the function.
|
||||
|
||||
|
||||
#### IDTokenVerifier
|
||||
|
||||
`IDTokenVerifier` interface has become a struct type. `NewIDTokenVerifier` now returns a pointer to `IDTokenVerifier`.
|
||||
Variable and struct fields declarations need to be changed from `rp.IDTokenVerifier` to `*rp.AccessTokenVerifier`.
|
||||
|
||||
```bash
|
||||
find . -type f -name '*.go' | xargs sed -i \
|
||||
-e 's/\brp\.IDTokenVerifier\b/*rp.IDTokenVerifier/g'
|
||||
```
|
||||
|
||||
### client/rs
|
||||
|
||||
```go
|
||||
import "github.com/zitadel/oidc/v3/pkg/client/rs"
|
||||
```
|
||||
|
||||
#### NewResourceServer
|
||||
|
||||
The `NewResourceServerClientCredentials` and `NewResourceServerJWTProfile` constructor functions now take a context as first argument.
|
||||
|
||||
```bash
|
||||
find . -type f -name '*.go' | xargs sed -i \
|
||||
-e 's/rs\.NewResourceServerClientCredentials(/rs.NewResourceServerClientCredentials(context.TODO(), /g' \
|
||||
-e 's/rs\.NewResourceServerJWTProfile(/rs.NewResourceServerJWTProfile(context.TODO(), /g'
|
||||
```
|
||||
|
||||
#### Introspect
|
||||
|
||||
`Introspect` is now generic and requires a type argument for the returned introspection response. For most use cases `*oidc.IntrospectionResponse` can be used as type argument. Any other response type if type-safe access to [custom claims](https://pkg.go.dev/github.com/zitadel/oidc/v3/pkg/client/rs#example-Introspect-Custom) is required.
|
||||
|
||||
```bash
|
||||
find . -type f -name '*.go' | xargs sed -i \
|
||||
-e 's/rs\.Introspect(/rs.Introspect[*oidc.IntrospectionResponse](/g'
|
||||
```
|
||||
|
||||
### client/tokenexchange
|
||||
|
||||
The `TokenExchanger` constructor functions `NewTokenExchanger` and `NewTokenExchangerClientCredentials` now take a context as first argument.
|
||||
As well as the `ExchangeToken` function.
|
||||
|
||||
```bash
|
||||
find . -type f -name '*.go' | xargs sed -i \
|
||||
-e 's/tokenexchange\.NewTokenExchanger(/tokenexchange.NewTokenExchanger(context.TODO(), /g' \
|
||||
-e 's/tokenexchange\.NewTokenExchangerClientCredentials(/tokenexchange.NewTokenExchangerClientCredentials(context.TODO(), /g' \
|
||||
-e 's/tokenexchange\.ExchangeToken(/tokenexchange.ExchangeToken(context.TODO(), /g'
|
||||
```
|
||||
|
||||
### oidc
|
||||
|
||||
#### SpaceDelimitedArray
|
||||
|
||||
The `SpaceDelimitedArray` type's `Encode()` function has been renamed to `String()` so it implements the `fmt.Stringer` interface. If the `Encode` method was called by a package consumer, it should be changed manually.
|
||||
|
||||
#### Verifier
|
||||
|
||||
The `Verifier` interface as been changed into a struct type. The struct type is aliased in the `op` and `rp` packages for the specific token use cases. See the relevant section above.
|
||||
|
||||
### Full script
|
||||
|
||||
For the courageous this is the full `sed` script which combines all the steps described above.
|
||||
It should migrate most of the code in a repository to a more-or-less compilable state,
|
||||
using defaults such as `context.TODO()` where possible.
|
||||
|
||||
Warnings:
|
||||
- Again, this is written for **GNU sed** not the posix variant.
|
||||
- Assumes imports that use the package names, not aliases.
|
||||
- Do this on a project with version control (eg Git), that allows you to rollback if things went wrong.
|
||||
- The script has been tested on the [ZITADEL](https://github.com/zitadel/zitadel) project, but we do not use all affected symbols. Parts of the script are mere guesswork.
|
||||
|
||||
```bash
|
||||
go get -u github.com/zitadel/oidc/v3
|
||||
find . -type f -name '*.go' | xargs sed -i \
|
||||
-e 's/github\.com\/zitadel\/oidc\/v2/github.com\/zitadel\/oidc\/v3/g' \
|
||||
-e 's/gopkg.in\/square\/go-jose\.v2/github.com\/go-jose\/go-jose\/v3/g' \
|
||||
-e 's/\bAuthRequestError(w, r, authReq, err, authorizer.Encoder())/AuthRequestError(w, r, authReq, err, authorizer)/g' \
|
||||
-e 's/\bop\.AccessTokenVerifier\b/*op.AccessTokenVerifier/g' \
|
||||
-e 's/\bop\.JWTProfileVerifier\b/*op.JWTProfileVerifier/g' \
|
||||
-e 's/\bop\.IDTokenHintVerifier\b/*op.IDTokenHintVerifier/g' \
|
||||
-e 's/AuthorizationEndpoint() Endpoint/AuthorizationEndpoint() *Endpoint/g' \
|
||||
-e 's/TokenEndpoint() Endpoint/TokenEndpoint() *Endpoint/g' \
|
||||
-e 's/IntrospectionEndpoint() Endpoint/IntrospectionEndpoint() *Endpoint/g' \
|
||||
-e 's/UserinfoEndpoint() Endpoint/UserinfoEndpoint() *Endpoint/g' \
|
||||
-e 's/RevocationEndpoint() Endpoint/RevocationEndpoint() *Endpoint/g' \
|
||||
-e 's/EndSessionEndpoint() Endpoint/EndSessionEndpoint() *Endpoint/g' \
|
||||
-e 's/KeysEndpoint() Endpoint/KeysEndpoint() *Endpoint/g' \
|
||||
-e 's/DeviceAuthorizationEndpoint() Endpoint/DeviceAuthorizationEndpoint() *Endpoint/g' \
|
||||
-e 's/op\.CreateDiscoveryConfig(/op.CreateDiscoveryConfig(context.TODO(), /g' \
|
||||
-e 's/op\.AuthorizeCodeChallenge(tokenReq/op.AuthorizeCodeChallenge(tokenReq.CodeVerifier/g' \
|
||||
-e 's/client\.Discover(/client.Discover(context.TODO(), /g' \
|
||||
-e 's/client\.CallTokenEndpoint(/client.CallTokenEndpoint(context.TODO(), /g' \
|
||||
-e 's/client\.CallEndSessionEndpoint(/client.CallEndSessionEndpoint(context.TODO(), /g' \
|
||||
-e 's/client\.CallRevokeEndpoint(/client.CallRevokeEndpoint(context.TODO(), /g' \
|
||||
-e 's/client\.CallTokenExchangeEndpoint(/client.CallTokenExchangeEndpoint(context.TODO(), /g' \
|
||||
-e 's/client\.CallDeviceAuthorizationEndpoint(/client.CallDeviceAuthorizationEndpoint(context.TODO(), /g' \
|
||||
-e 's/client\.JWTProfileExchange(/client.JWTProfileExchange(context.TODO(), /g' \
|
||||
-e 's/profile\.NewJWTProfileTokenSource(/profile.NewJWTProfileTokenSource(context.TODO(), /g' \
|
||||
-e 's/profile\.NewJWTProfileTokenSourceFromKeyFileData(/profile.NewJWTProfileTokenSourceFromKeyFileData(context.TODO(), /g' \
|
||||
-e 's/profile\.NewJWTProfileTokenSourceFromKeyFile(/profile.NewJWTProfileTokenSourceFromKeyFile(context.TODO(), /g' \
|
||||
-e 's/rp\.NewRelyingPartyOIDC(/rp.NewRelyingPartyOIDC(context.TODO(), /g' \
|
||||
-e 's/rp\.EndSession(/rp.EndSession(context.TODO(), /g' \
|
||||
-e 's/rp\.RevokeToken(/rp.RevokeToken(context.TODO(), /g' \
|
||||
-e 's/rp\.DeviceAuthorization(/rp.DeviceAuthorization(context.TODO(), /g' \
|
||||
-e 's/rp\.RefreshAccessToken(/rp.RefreshTokens[*oidc.IDTokenClaims](context.TODO(), /g' \
|
||||
-e 's/rp\.Userinfo(/rp.Userinfo[*oidc.UserInfo](context.TODO(), /g' \
|
||||
-e 's/\brp\.IDTokenVerifier\b/*rp.IDTokenVerifier/g' \
|
||||
-e 's/rs\.NewResourceServerClientCredentials(/rs.NewResourceServerClientCredentials(context.TODO(), /g' \
|
||||
-e 's/rs\.NewResourceServerJWTProfile(/rs.NewResourceServerJWTProfile(context.TODO(), /g' \
|
||||
-e 's/rs\.Introspect(/rs.Introspect[*oidc.IntrospectionResponse](/g' \
|
||||
-e 's/tokenexchange\.NewTokenExchanger(/tokenexchange.NewTokenExchanger(context.TODO(), /g' \
|
||||
-e 's/tokenexchange\.NewTokenExchangerClientCredentials(/tokenexchange.NewTokenExchangerClientCredentials(context.TODO(), /g' \
|
||||
-e 's/tokenexchange\.ExchangeToken(/tokenexchange.ExchangeToken(context.TODO(), /g'
|
||||
go mod tidy
|
||||
```
|
|
@ -1,7 +1,6 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
@ -10,11 +9,11 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/client/rs"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
"github.com/zitadel/oidc/pkg/client/rs"
|
||||
"github.com/zitadel/oidc/pkg/oidc"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -28,12 +27,12 @@ func main() {
|
|||
port := os.Getenv("PORT")
|
||||
issuer := os.Getenv("ISSUER")
|
||||
|
||||
provider, err := rs.NewResourceServerFromKeyFile(context.TODO(), issuer, keyPath)
|
||||
provider, err := rs.NewResourceServerFromKeyFile(issuer, keyPath)
|
||||
if err != nil {
|
||||
logrus.Fatalf("error creating provider %s", err.Error())
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
router := mux.NewRouter()
|
||||
|
||||
// public url accessible without any authorization
|
||||
// will print `OK` and current timestamp
|
||||
|
@ -48,7 +47,7 @@ func main() {
|
|||
if !ok {
|
||||
return
|
||||
}
|
||||
resp, err := rs.Introspect[*oidc.IntrospectionResponse](r.Context(), provider, token)
|
||||
resp, err := rs.Introspect(r.Context(), provider, token)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
|
@ -69,15 +68,15 @@ func main() {
|
|||
if !ok {
|
||||
return
|
||||
}
|
||||
resp, err := rs.Introspect[*oidc.IntrospectionResponse](r.Context(), provider, token)
|
||||
resp, err := rs.Introspect(r.Context(), provider, token)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
requestedClaim := chi.URLParam(r, "claim")
|
||||
requestedValue := chi.URLParam(r, "value")
|
||||
|
||||
value, ok := resp.Claims[requestedClaim].(string)
|
||||
params := mux.Vars(r)
|
||||
requestedClaim := params["claim"]
|
||||
requestedValue := params["value"]
|
||||
value, ok := resp.GetClaim(requestedClaim).(string)
|
||||
if !ok || value == "" || value != requestedValue {
|
||||
http.Error(w, "claim does not match", http.StatusForbidden)
|
||||
return
|
||||
|
|
|
@ -1,23 +1,19 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/client/rp"
|
||||
httphelper "git.christmann.info/LARA/zitadel-oidc/v3/pkg/http"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
"github.com/zitadel/logging"
|
||||
"github.com/zitadel/oidc/pkg/client/rp"
|
||||
httphelper "github.com/zitadel/oidc/pkg/http"
|
||||
"github.com/zitadel/oidc/pkg/oidc"
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -32,31 +28,13 @@ func main() {
|
|||
issuer := os.Getenv("ISSUER")
|
||||
port := os.Getenv("PORT")
|
||||
scopes := strings.Split(os.Getenv("SCOPES"), " ")
|
||||
responseMode := os.Getenv("RESPONSE_MODE")
|
||||
|
||||
redirectURI := fmt.Sprintf("http://localhost:%v%v", port, callbackPath)
|
||||
cookieHandler := httphelper.NewCookieHandler(key, key, httphelper.WithUnsecure())
|
||||
|
||||
logger := slog.New(
|
||||
slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
AddSource: true,
|
||||
Level: slog.LevelDebug,
|
||||
}),
|
||||
)
|
||||
client := &http.Client{
|
||||
Timeout: time.Minute,
|
||||
}
|
||||
// enable outgoing request logging
|
||||
logging.EnableHTTPClient(client,
|
||||
logging.WithClientGroup("client"),
|
||||
)
|
||||
|
||||
options := []rp.Option{
|
||||
rp.WithCookieHandler(cookieHandler),
|
||||
rp.WithVerifierOpts(rp.WithIssuedAtOffset(5 * time.Second)),
|
||||
rp.WithHTTPClient(client),
|
||||
rp.WithLogger(logger),
|
||||
rp.WithSigningAlgsFromDiscovery(),
|
||||
}
|
||||
if clientSecret == "" {
|
||||
options = append(options, rp.WithPKCE(cookieHandler))
|
||||
|
@ -65,10 +43,7 @@ func main() {
|
|||
options = append(options, rp.WithJWTProfile(rp.SignerFromKeyPath(keyPath)))
|
||||
}
|
||||
|
||||
// One can add a logger to the context,
|
||||
// pre-defining log attributes as required.
|
||||
ctx := logging.ToContext(context.TODO(), logger)
|
||||
provider, err := rp.NewRelyingPartyOIDC(ctx, issuer, clientID, clientSecret, redirectURI, scopes, options...)
|
||||
provider, err := rp.NewRelyingPartyOIDC(issuer, clientID, clientSecret, redirectURI, scopes, options...)
|
||||
if err != nil {
|
||||
logrus.Fatalf("error creating provider %s", err.Error())
|
||||
}
|
||||
|
@ -79,37 +54,20 @@ func main() {
|
|||
return uuid.New().String()
|
||||
}
|
||||
|
||||
urlOptions := []rp.URLParamOpt{
|
||||
rp.WithPromptURLParam("Welcome back!"),
|
||||
}
|
||||
|
||||
if responseMode != "" {
|
||||
urlOptions = append(urlOptions, rp.WithResponseModeURLParam(oidc.ResponseMode(responseMode)))
|
||||
}
|
||||
|
||||
// register the AuthURLHandler at your preferred path.
|
||||
// the AuthURLHandler creates the auth request and redirects the user to the auth server.
|
||||
// including state handling with secure cookie and the possibility to use PKCE.
|
||||
// Prompts can optionally be set to inform the server of
|
||||
// any messages that need to be prompted back to the user.
|
||||
http.Handle("/login", rp.AuthURLHandler(
|
||||
state,
|
||||
provider,
|
||||
urlOptions...,
|
||||
))
|
||||
http.Handle("/login", rp.AuthURLHandler(state, provider, rp.WithPromptURLParam("Welcome back!")))
|
||||
|
||||
// for demonstration purposes the returned userinfo response is written as JSON object onto response
|
||||
marshalUserinfo := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string, rp rp.RelyingParty, info *oidc.UserInfo) {
|
||||
fmt.Println("access token", tokens.AccessToken)
|
||||
fmt.Println("refresh token", tokens.RefreshToken)
|
||||
fmt.Println("id token", tokens.IDToken)
|
||||
|
||||
marshalUserinfo := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens, state string, rp rp.RelyingParty, info oidc.UserInfo) {
|
||||
data, err := json.Marshal(info)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("content-type", "application/json")
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
|
@ -124,31 +82,6 @@ func main() {
|
|||
// w.Write(data)
|
||||
//}
|
||||
|
||||
// you can also try token exchange flow
|
||||
//
|
||||
// requestTokenExchange := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens, state string, rp rp.RelyingParty, info oidc.UserInfo) {
|
||||
// data := make(url.Values)
|
||||
// data.Set("grant_type", string(oidc.GrantTypeTokenExchange))
|
||||
// data.Set("requested_token_type", string(oidc.IDTokenType))
|
||||
// data.Set("subject_token", tokens.RefreshToken)
|
||||
// data.Set("subject_token_type", string(oidc.RefreshTokenType))
|
||||
// data.Add("scope", "profile custom_scope:impersonate:id2")
|
||||
|
||||
// client := &http.Client{}
|
||||
// r2, _ := http.NewRequest(http.MethodPost, issuer+"/oauth/token", strings.NewReader(data.Encode()))
|
||||
// // r2.Header.Add("Authorization", "Basic "+"d2ViOnNlY3JldA==")
|
||||
// r2.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
// r2.SetBasicAuth("web", "secret")
|
||||
|
||||
// resp, _ := client.Do(r2)
|
||||
// fmt.Println(resp.Status)
|
||||
|
||||
// b, _ := io.ReadAll(resp.Body)
|
||||
// resp.Body.Close()
|
||||
|
||||
// w.Write(b)
|
||||
// }
|
||||
|
||||
// register the CodeExchangeHandler at the callbackPath
|
||||
// the CodeExchangeHandler handles the auth response, creates the token request and calls the callback function
|
||||
// with the returned tokens from the token endpoint
|
||||
|
@ -160,22 +93,8 @@ func main() {
|
|||
//
|
||||
// http.Handle(callbackPath, rp.CodeExchangeHandler(marshalToken, provider))
|
||||
|
||||
// simple counter for request IDs
|
||||
var counter atomic.Int64
|
||||
// enable incomming request logging
|
||||
mw := logging.Middleware(
|
||||
logging.WithLogger(logger),
|
||||
logging.WithGroup("server"),
|
||||
logging.WithIDFunc(func() slog.Attr {
|
||||
return slog.Int64("id", counter.Add(1))
|
||||
}),
|
||||
)
|
||||
|
||||
lis := fmt.Sprintf("127.0.0.1:%s", port)
|
||||
logger.Info("server listening, press ctrl+c to stop", "addr", lis)
|
||||
err = http.ListenAndServe(lis, mw(http.DefaultServeMux))
|
||||
if err != http.ErrServerClosed {
|
||||
logger.Error("server terminated", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logrus.Infof("listening on http://%s/", lis)
|
||||
logrus.Info("press ctrl+c to stop")
|
||||
logrus.Fatal(http.ListenAndServe(lis, nil))
|
||||
}
|
||||
|
|
|
@ -1,95 +0,0 @@
|
|||
// Command device is an example Oauth2 Device Authorization Grant app.
|
||||
// It creates a new Device Authorization request on the Issuer and then polls for tokens.
|
||||
// The user is then prompted to visit a URL and enter the user code.
|
||||
// Or, the complete URL can be used instead to omit manual entry.
|
||||
// In practice then can be a "magic link" in the form or a QR.
|
||||
//
|
||||
// The following environment variables are used for configuration:
|
||||
//
|
||||
// ISSUER: URL to the OP, required.
|
||||
// CLIENT_ID: ID of the application, required.
|
||||
// CLIENT_SECRET: Secret to authenticate the app using basic auth. Only required if the OP expects this type of authentication.
|
||||
// KEY_PATH: Path to a private key file, used to for JWT authentication of the App. Only required if the OP expects this type of authentication.
|
||||
// SCOPES: Scopes of the Authentication Request. Optional.
|
||||
//
|
||||
// Basic usage:
|
||||
//
|
||||
// cd example/client/device
|
||||
// export ISSUER="http://localhost:9000" CLIENT_ID="246048465824634593@demo"
|
||||
//
|
||||
// Get an Access Token:
|
||||
//
|
||||
// SCOPES="email profile" go run .
|
||||
//
|
||||
// Get an Access Token and ID Token:
|
||||
//
|
||||
// SCOPES="email profile openid" go run .
|
||||
//
|
||||
// Get an Access Token and Refresh Token
|
||||
//
|
||||
// SCOPES="email profile offline_access" go run .
|
||||
//
|
||||
// Get Access, Refresh and ID Tokens:
|
||||
//
|
||||
// SCOPES="email profile offline_access openid" go run .
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/client/rp"
|
||||
httphelper "git.christmann.info/LARA/zitadel-oidc/v3/pkg/http"
|
||||
)
|
||||
|
||||
var (
|
||||
key = []byte("test1234test1234")
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT)
|
||||
defer stop()
|
||||
|
||||
clientID := os.Getenv("CLIENT_ID")
|
||||
clientSecret := os.Getenv("CLIENT_SECRET")
|
||||
keyPath := os.Getenv("KEY_PATH")
|
||||
issuer := os.Getenv("ISSUER")
|
||||
scopes := strings.Split(os.Getenv("SCOPES"), " ")
|
||||
|
||||
cookieHandler := httphelper.NewCookieHandler(key, key, httphelper.WithUnsecure())
|
||||
|
||||
var options []rp.Option
|
||||
if clientSecret == "" {
|
||||
options = append(options, rp.WithPKCE(cookieHandler))
|
||||
}
|
||||
if keyPath != "" {
|
||||
options = append(options, rp.WithJWTProfile(rp.SignerFromKeyPath(keyPath)))
|
||||
}
|
||||
|
||||
provider, err := rp.NewRelyingPartyOIDC(ctx, issuer, clientID, clientSecret, "", scopes, options...)
|
||||
if err != nil {
|
||||
logrus.Fatalf("error creating provider %s", err.Error())
|
||||
}
|
||||
|
||||
logrus.Info("starting device authorization flow")
|
||||
resp, err := rp.DeviceAuthorization(ctx, scopes, provider, nil)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
logrus.Info("resp", resp)
|
||||
fmt.Printf("\nPlease browse to %s and enter code %s\n", resp.VerificationURI, resp.UserCode)
|
||||
|
||||
logrus.Info("start polling")
|
||||
token, err := rp.DeviceAccessToken(ctx, resp.DeviceCode, time.Duration(resp.Interval)*time.Second, provider)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
logrus.Infof("successfully obtained token: %#v", token)
|
||||
}
|
|
@ -10,10 +10,9 @@ import (
|
|||
"golang.org/x/oauth2"
|
||||
githubOAuth "golang.org/x/oauth2/github"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/client/rp"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/client/rp/cli"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/http"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
"github.com/zitadel/oidc/pkg/client/rp"
|
||||
"github.com/zitadel/oidc/pkg/client/rp/cli"
|
||||
"github.com/zitadel/oidc/pkg/http"
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -44,7 +43,7 @@ func main() {
|
|||
state := func() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
token := cli.CodeFlow[*oidc.IDTokenClaims](ctx, relyingParty, callbackPath, port, state)
|
||||
token := cli.CodeFlow(ctx, relyingParty, callbackPath, port, state)
|
||||
|
||||
client := github.NewClient(relyingParty.OAuthConfig().Client(ctx, token.Token))
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ import (
|
|||
"github.com/sirupsen/logrus"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/client/profile"
|
||||
"github.com/zitadel/oidc/pkg/client/profile"
|
||||
)
|
||||
|
||||
var client = http.DefaultClient
|
||||
|
@ -25,7 +25,7 @@ func main() {
|
|||
scopes := strings.Split(os.Getenv("SCOPES"), " ")
|
||||
|
||||
if keyPath != "" {
|
||||
ts, err := profile.NewJWTProfileTokenSourceFromKeyFile(context.TODO(), issuer, keyPath, scopes)
|
||||
ts, err := profile.NewJWTProfileTokenSourceFromKeyFile(issuer, keyPath, scopes)
|
||||
if err != nil {
|
||||
logrus.Fatalf("error creating token source %s", err.Error())
|
||||
}
|
||||
|
@ -76,7 +76,7 @@ func main() {
|
|||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ts, err := profile.NewJWTProfileTokenSourceFromKeyFileData(context.TODO(), issuer, key, scopes)
|
||||
ts, err := profile.NewJWTProfileTokenSourceFromKeyFileData(issuer, key, scopes)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
@ -125,7 +125,7 @@ func main() {
|
|||
testURL := r.Form.Get("url")
|
||||
var data struct {
|
||||
URL string
|
||||
Response any
|
||||
Response interface{}
|
||||
}
|
||||
if testURL != "" {
|
||||
data.URL = testURL
|
||||
|
@ -149,7 +149,7 @@ func main() {
|
|||
logrus.Fatal(http.ListenAndServe("127.0.0.1:"+port, nil))
|
||||
}
|
||||
|
||||
func callExampleEndpoint(client *http.Client, testURL string) (any, error) {
|
||||
func callExampleEndpoint(client *http.Client, testURL string) (interface{}, error) {
|
||||
req, err := http.NewRequest("GET", testURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
@ -5,6 +5,7 @@ Package example contains some example of the various use of this library:
|
|||
/app web app / RP demonstrating authorization code flow using various authentication methods (code, PKCE, JWT profile)
|
||||
/github example of the extended OAuth2 library, providing an HTTP client with a reuse token source
|
||||
/service demonstration of JWT Profile Authorization Grant
|
||||
/server examples of an OpenID Provider implementations (including dynamic) with some very basic
|
||||
/server example of an OpenID Provider implementation including some very basic login UI
|
||||
|
||||
*/
|
||||
package example
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// default port for the http server to run
|
||||
DefaultIssuerPort = "9998"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
RedirectURI []string
|
||||
UsersFile string
|
||||
}
|
||||
|
||||
// FromEnvVars loads configuration parameters from environment variables.
|
||||
// If there is no such variable defined, then use default values.
|
||||
func FromEnvVars(defaults *Config) *Config {
|
||||
if defaults == nil {
|
||||
defaults = &Config{}
|
||||
}
|
||||
cfg := &Config{
|
||||
Port: defaults.Port,
|
||||
RedirectURI: defaults.RedirectURI,
|
||||
UsersFile: defaults.UsersFile,
|
||||
}
|
||||
if value, ok := os.LookupEnv("PORT"); ok {
|
||||
cfg.Port = value
|
||||
}
|
||||
if value, ok := os.LookupEnv("USERS_FILE"); ok {
|
||||
cfg.UsersFile = value
|
||||
}
|
||||
if value, ok := os.LookupEnv("REDIRECT_URI"); ok {
|
||||
cfg.RedirectURI = strings.Split(value, ",")
|
||||
}
|
||||
return cfg
|
||||
}
|
|
@ -1,77 +0,0 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFromEnvVars(t *testing.T) {
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
defaults *Config
|
||||
want *Config
|
||||
}{
|
||||
{
|
||||
name: "no vars, no default values",
|
||||
env: map[string]string{},
|
||||
want: &Config{},
|
||||
},
|
||||
{
|
||||
name: "no vars, only defaults",
|
||||
env: map[string]string{},
|
||||
defaults: &Config{
|
||||
Port: "6666",
|
||||
UsersFile: "/default/user/path",
|
||||
RedirectURI: []string{"re", "direct", "uris"},
|
||||
},
|
||||
want: &Config{
|
||||
Port: "6666",
|
||||
UsersFile: "/default/user/path",
|
||||
RedirectURI: []string{"re", "direct", "uris"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "overriding default values",
|
||||
env: map[string]string{
|
||||
"PORT": "1234",
|
||||
"USERS_FILE": "/path/to/users",
|
||||
"REDIRECT_URI": "http://redirect/redirect",
|
||||
},
|
||||
defaults: &Config{
|
||||
Port: "6666",
|
||||
UsersFile: "/default/user/path",
|
||||
RedirectURI: []string{"re", "direct", "uris"},
|
||||
},
|
||||
want: &Config{
|
||||
Port: "1234",
|
||||
UsersFile: "/path/to/users",
|
||||
RedirectURI: []string{"http://redirect/redirect"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple redirect uris",
|
||||
env: map[string]string{
|
||||
"REDIRECT_URI": "http://host_1,http://host_2,http://host_3",
|
||||
},
|
||||
want: &Config{
|
||||
RedirectURI: []string{
|
||||
"http://host_1", "http://host_2", "http://host_3",
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
os.Clearenv()
|
||||
for k, v := range tc.env {
|
||||
os.Setenv(k, v)
|
||||
}
|
||||
cfg := FromEnvVars(tc.defaults)
|
||||
if fmt.Sprint(cfg) != fmt.Sprint(tc.want) {
|
||||
t.Errorf("Expected FromEnvVars()=%q, but got %q", tc.want, cfg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,113 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/op"
|
||||
)
|
||||
|
||||
const (
|
||||
queryAuthRequestID = "authRequestID"
|
||||
)
|
||||
|
||||
var (
|
||||
loginTmpl, _ = template.New("login").Parse(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Login</title>
|
||||
</head>
|
||||
<body style="display: flex; align-items: center; justify-content: center; height: 100vh;">
|
||||
<form method="POST" action="/login/username" style="height: 200px; width: 200px;">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<div>
|
||||
<label for="username">Username:</label>
|
||||
<input id="username" name="username" style="width: 100%">
|
||||
</div>
|
||||
<div>
|
||||
<label for="password">Password:</label>
|
||||
<input id="password" name="password" style="width: 100%">
|
||||
</div>
|
||||
<p style="color:red; min-height: 1rem;">{{.Error}}</p>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>`)
|
||||
)
|
||||
|
||||
type login struct {
|
||||
authenticate authenticate
|
||||
router chi.Router
|
||||
callback func(context.Context, string) string
|
||||
}
|
||||
|
||||
func NewLogin(authenticate authenticate, callback func(context.Context, string) string, issuerInterceptor *op.IssuerInterceptor) *login {
|
||||
l := &login{
|
||||
authenticate: authenticate,
|
||||
callback: callback,
|
||||
}
|
||||
l.createRouter(issuerInterceptor)
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *login) createRouter(issuerInterceptor *op.IssuerInterceptor) {
|
||||
l.router = chi.NewRouter()
|
||||
l.router.Get("/username", l.loginHandler)
|
||||
l.router.With(issuerInterceptor.Handler).Post("/username", l.checkLoginHandler)
|
||||
}
|
||||
|
||||
type authenticate interface {
|
||||
CheckUsernamePassword(ctx context.Context, username, password, id string) error
|
||||
}
|
||||
|
||||
func (l *login) loginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("cannot parse form:%s", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
//the oidc package will pass the id of the auth request as query parameter
|
||||
//we will use this id through the login process and therefore pass it to the login page
|
||||
renderLogin(w, r.FormValue(queryAuthRequestID), nil)
|
||||
}
|
||||
|
||||
func renderLogin(w http.ResponseWriter, id string, err error) {
|
||||
var errMsg string
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
data := &struct {
|
||||
ID string
|
||||
Error string
|
||||
}{
|
||||
ID: id,
|
||||
Error: errMsg,
|
||||
}
|
||||
err = loginTmpl.Execute(w, data)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *login) checkLoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("cannot parse form:%s", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
id := r.FormValue("id")
|
||||
err = l.authenticate.CheckUsernamePassword(r.Context(), username, password, id)
|
||||
if err != nil {
|
||||
renderLogin(w, id, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, l.callback(r.Context(), id), http.StatusFound)
|
||||
}
|
|
@ -1,138 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"golang.org/x/text/language"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/example/server/storage"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/op"
|
||||
)
|
||||
|
||||
const (
|
||||
pathLoggedOut = "/logged-out"
|
||||
)
|
||||
|
||||
var (
|
||||
hostnames = []string{
|
||||
"localhost", //note that calling 127.0.0.1 / ::1 won't work as the hostname does not match
|
||||
"oidc.local", //add this to your hosts file (pointing to 127.0.0.1)
|
||||
//feel free to add more...
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
storage.RegisterClients(
|
||||
storage.NativeClient("native"),
|
||||
storage.WebClient("web", "secret"),
|
||||
storage.WebClient("api", "secret"),
|
||||
)
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
|
||||
port := "9998"
|
||||
issuers := make([]string, len(hostnames))
|
||||
for i, hostname := range hostnames {
|
||||
issuers[i] = fmt.Sprintf("http://%s:%s/", hostname, port)
|
||||
}
|
||||
|
||||
//the OpenID Provider requires a 32-byte key for (token) encryption
|
||||
//be sure to create a proper crypto random key and manage it securely!
|
||||
key := sha256.Sum256([]byte("test"))
|
||||
|
||||
router := chi.NewRouter()
|
||||
|
||||
//for simplicity, we provide a very small default page for users who have signed out
|
||||
router.HandleFunc(pathLoggedOut, func(w http.ResponseWriter, req *http.Request) {
|
||||
_, err := w.Write([]byte("signed out successfully"))
|
||||
if err != nil {
|
||||
log.Printf("error serving logged out page: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
//the OpenIDProvider interface needs a Storage interface handling various checks and state manipulations
|
||||
//this might be the layer for accessing your database
|
||||
//in this example it will be handled in-memory
|
||||
//the NewMultiStorage is able to handle multiple issuers
|
||||
storage := storage.NewMultiStorage(issuers)
|
||||
|
||||
//creation of the OpenIDProvider with the just created in-memory Storage
|
||||
provider, err := newDynamicOP(ctx, storage, key)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
//the provider will only take care of the OpenID Protocol, so there must be some sort of UI for the login process
|
||||
//for the simplicity of the example this means a simple page with username and password field
|
||||
//be sure to provide an IssuerInterceptor with the IssuerFromRequest from the OP so the login can select / and pass it to the storage
|
||||
l := NewLogin(storage, op.AuthCallbackURL(provider), op.NewIssuerInterceptor(provider.IssuerFromRequest))
|
||||
|
||||
//regardless of how many pages / steps there are in the process, the UI must be registered in the router,
|
||||
//so we will direct all calls to /login to the login UI
|
||||
router.Mount("/login/", http.StripPrefix("/login", l.router))
|
||||
|
||||
//we register the http handler of the OP on the root, so that the discovery endpoint (/.well-known/openid-configuration)
|
||||
//is served on the correct path
|
||||
//
|
||||
//if your issuer ends with a path (e.g. http://localhost:9998/custom/path/),
|
||||
//then you would have to set the path prefix (/custom/path/):
|
||||
//router.PathPrefix("/custom/path/").Handler(http.StripPrefix("/custom/path", provider.HttpHandler()))
|
||||
router.Mount("/", provider)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: router,
|
||||
}
|
||||
err = server.ListenAndServe()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
<-ctx.Done()
|
||||
}
|
||||
|
||||
// newDynamicOP will create an OpenID Provider for localhost on a specified port with a given encryption key
|
||||
// and a predefined default logout uri
|
||||
// it will enable all options (see descriptions)
|
||||
func newDynamicOP(ctx context.Context, storage op.Storage, key [32]byte) (*op.Provider, error) {
|
||||
config := &op.Config{
|
||||
CryptoKey: key,
|
||||
|
||||
//will be used if the end_session endpoint is called without a post_logout_redirect_uri
|
||||
DefaultLogoutRedirectURI: pathLoggedOut,
|
||||
|
||||
//enables code_challenge_method S256 for PKCE (and therefore PKCE in general)
|
||||
CodeMethodS256: true,
|
||||
|
||||
//enables additional client_id/client_secret authentication by form post (not only HTTP Basic Auth)
|
||||
AuthMethodPost: true,
|
||||
|
||||
//enables additional authentication by using private_key_jwt
|
||||
AuthMethodPrivateKeyJWT: true,
|
||||
|
||||
//enables refresh_token grant use
|
||||
GrantTypeRefreshToken: true,
|
||||
|
||||
//enables use of the `request` Object parameter
|
||||
RequestObjectSupported: true,
|
||||
|
||||
//this example has only static texts (in English), so we'll set the here accordingly
|
||||
SupportedUILocales: []language.Tag{language.English},
|
||||
}
|
||||
handler, err := op.NewDynamicOpenIDProvider("/", config, storage,
|
||||
//we must explicitly allow the use of the http issuer
|
||||
op.WithAllowInsecure(),
|
||||
//as an example on how to customize an endpoint this will change the authorization_endpoint from /authorize to /auth
|
||||
op.WithCustomAuthEndpoint(op.NewEndpoint("auth")),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return handler, nil
|
||||
}
|
|
@ -1,204 +0,0 @@
|
|||
package exampleop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/op"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type deviceAuthenticate interface {
|
||||
CheckUsernamePasswordSimple(username, password string) error
|
||||
op.DeviceAuthorizationStorage
|
||||
|
||||
// GetDeviceAuthorizationByUserCode resturns the current state of the device authorization flow,
|
||||
// identified by the user code.
|
||||
GetDeviceAuthorizationByUserCode(ctx context.Context, userCode string) (*op.DeviceAuthorizationState, error)
|
||||
|
||||
// CompleteDeviceAuthorization marks a device authorization entry as Completed,
|
||||
// identified by userCode. The Subject is added to the state, so that
|
||||
// GetDeviceAuthorizatonState can use it to create a new Access Token.
|
||||
CompleteDeviceAuthorization(ctx context.Context, userCode, subject string) error
|
||||
|
||||
// DenyDeviceAuthorization marks a device authorization entry as Denied.
|
||||
DenyDeviceAuthorization(ctx context.Context, userCode string) error
|
||||
}
|
||||
|
||||
type deviceLogin struct {
|
||||
storage deviceAuthenticate
|
||||
cookie *securecookie.SecureCookie
|
||||
}
|
||||
|
||||
func registerDeviceAuth(storage deviceAuthenticate, router chi.Router) {
|
||||
l := &deviceLogin{
|
||||
storage: storage,
|
||||
cookie: securecookie.New(securecookie.GenerateRandomKey(32), nil),
|
||||
}
|
||||
|
||||
router.HandleFunc("/", l.userCodeHandler)
|
||||
router.Post("/login", l.loginHandler)
|
||||
router.HandleFunc("/confirm", l.confirmHandler)
|
||||
}
|
||||
|
||||
func renderUserCode(w io.Writer, err error) {
|
||||
data := struct {
|
||||
Error string
|
||||
}{
|
||||
Error: errMsg(err),
|
||||
}
|
||||
|
||||
if err := templates.ExecuteTemplate(w, "usercode", data); err != nil {
|
||||
logrus.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func renderDeviceLogin(w http.ResponseWriter, userCode string, err error) {
|
||||
data := &struct {
|
||||
UserCode string
|
||||
Error string
|
||||
}{
|
||||
UserCode: userCode,
|
||||
Error: errMsg(err),
|
||||
}
|
||||
if err = templates.ExecuteTemplate(w, "device_login", data); err != nil {
|
||||
logrus.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func renderConfirmPage(w http.ResponseWriter, username, clientID string, scopes []string) {
|
||||
data := &struct {
|
||||
Username string
|
||||
ClientID string
|
||||
Scopes []string
|
||||
}{
|
||||
Username: username,
|
||||
ClientID: clientID,
|
||||
Scopes: scopes,
|
||||
}
|
||||
if err := templates.ExecuteTemplate(w, "confirm_device", data); err != nil {
|
||||
logrus.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *deviceLogin) userCodeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
renderUserCode(w, err)
|
||||
return
|
||||
}
|
||||
userCode := r.Form.Get("user_code")
|
||||
if userCode == "" {
|
||||
if prompt, _ := url.QueryUnescape(r.Form.Get("prompt")); prompt != "" {
|
||||
err = errors.New(prompt)
|
||||
}
|
||||
renderUserCode(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
renderDeviceLogin(w, userCode, nil)
|
||||
}
|
||||
|
||||
func redirectBack(w http.ResponseWriter, r *http.Request, prompt string) {
|
||||
values := make(url.Values)
|
||||
values.Set("prompt", url.QueryEscape(prompt))
|
||||
|
||||
url := url.URL{
|
||||
Path: "/device",
|
||||
RawQuery: values.Encode(),
|
||||
}
|
||||
http.Redirect(w, r, url.String(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
const userCodeCookieName = "user_code"
|
||||
|
||||
type userCodeCookie struct {
|
||||
UserCode string
|
||||
UserName string
|
||||
}
|
||||
|
||||
func (d *deviceLogin) loginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
redirectBack(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
userCode := r.PostForm.Get("user_code")
|
||||
if userCode == "" {
|
||||
redirectBack(w, r, "missing user_code in request")
|
||||
return
|
||||
}
|
||||
username := r.PostForm.Get("username")
|
||||
if username == "" {
|
||||
redirectBack(w, r, "missing username in request")
|
||||
return
|
||||
}
|
||||
password := r.PostForm.Get("password")
|
||||
if password == "" {
|
||||
redirectBack(w, r, "missing password in request")
|
||||
return
|
||||
}
|
||||
|
||||
if err := d.storage.CheckUsernamePasswordSimple(username, password); err != nil {
|
||||
redirectBack(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
state, err := d.storage.GetDeviceAuthorizationByUserCode(r.Context(), userCode)
|
||||
if err != nil {
|
||||
redirectBack(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
encoded, err := d.cookie.Encode(userCodeCookieName, userCodeCookie{userCode, username})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
cookie := &http.Cookie{
|
||||
Name: userCodeCookieName,
|
||||
Value: encoded,
|
||||
Path: "/",
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
renderConfirmPage(w, username, state.ClientID, state.Scopes)
|
||||
}
|
||||
|
||||
func (d *deviceLogin) confirmHandler(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(userCodeCookieName)
|
||||
if err != nil {
|
||||
redirectBack(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
data := new(userCodeCookie)
|
||||
if err = d.cookie.Decode(userCodeCookieName, cookie.Value, &data); err != nil {
|
||||
redirectBack(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
if err = r.ParseForm(); err != nil {
|
||||
redirectBack(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
action := r.Form.Get("action")
|
||||
switch action {
|
||||
case "allowed":
|
||||
err = d.storage.CompleteDeviceAuthorization(r.Context(), data.UserCode, data.UserName)
|
||||
case "denied":
|
||||
err = d.storage.DenyDeviceAuthorization(r.Context(), data.UserCode)
|
||||
default:
|
||||
err = errors.New("action must be one of \"allow\" or \"deny\"")
|
||||
}
|
||||
if err != nil {
|
||||
redirectBack(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "Device authorization %s. You can now return to the device", action)
|
||||
}
|
|
@ -1,33 +1,65 @@
|
|||
package exampleop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/op"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
const (
|
||||
queryAuthRequestID = "authRequestID"
|
||||
)
|
||||
|
||||
var loginTmpl, _ = template.New("login").Parse(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Login</title>
|
||||
</head>
|
||||
<body style="display: flex; align-items: center; justify-content: center; height: 100vh;">
|
||||
<form method="POST" action="/login/username" style="height: 200px; width: 200px;">
|
||||
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
|
||||
<div>
|
||||
<label for="username">Username:</label>
|
||||
<input id="username" name="username" style="width: 100%">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password">Password:</label>
|
||||
<input id="password" name="password" style="width: 100%">
|
||||
</div>
|
||||
|
||||
<p style="color:red; min-height: 1rem;">{{.Error}}</p>
|
||||
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>`)
|
||||
|
||||
type login struct {
|
||||
authenticate authenticate
|
||||
router chi.Router
|
||||
callback func(context.Context, string) string
|
||||
router *mux.Router
|
||||
callback func(string) string
|
||||
}
|
||||
|
||||
func NewLogin(authenticate authenticate, callback func(context.Context, string) string, issuerInterceptor *op.IssuerInterceptor) *login {
|
||||
func NewLogin(authenticate authenticate, callback func(string) string) *login {
|
||||
l := &login{
|
||||
authenticate: authenticate,
|
||||
callback: callback,
|
||||
}
|
||||
l.createRouter(issuerInterceptor)
|
||||
l.createRouter()
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *login) createRouter(issuerInterceptor *op.IssuerInterceptor) {
|
||||
l.router = chi.NewRouter()
|
||||
l.router.Get("/username", l.loginHandler)
|
||||
l.router.Post("/username", issuerInterceptor.HandlerFunc(l.checkLoginHandler))
|
||||
func (l *login) createRouter() {
|
||||
l.router = mux.NewRouter()
|
||||
l.router.Path("/username").Methods("GET").HandlerFunc(l.loginHandler)
|
||||
l.router.Path("/username").Methods("POST").HandlerFunc(l.checkLoginHandler)
|
||||
}
|
||||
|
||||
type authenticate interface {
|
||||
|
@ -46,14 +78,18 @@ func (l *login) loginHandler(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func renderLogin(w http.ResponseWriter, id string, err error) {
|
||||
var errMsg string
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
data := &struct {
|
||||
ID string
|
||||
Error string
|
||||
}{
|
||||
ID: id,
|
||||
Error: errMsg(err),
|
||||
Error: errMsg,
|
||||
}
|
||||
err = templates.ExecuteTemplate(w, "login", data)
|
||||
err = loginTmpl.Execute(w, data)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
@ -73,5 +109,5 @@ func (l *login) checkLoginHandler(w http.ResponseWriter, r *http.Request) {
|
|||
renderLogin(w, id, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, l.callback(r.Context(), id), http.StatusFound)
|
||||
http.Redirect(w, r, l.callback(id), http.StatusFound)
|
||||
}
|
||||
|
|
|
@ -1,85 +1,77 @@
|
|||
package exampleop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"os"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/zitadel/logging"
|
||||
"github.com/gorilla/mux"
|
||||
"golang.org/x/text/language"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/op"
|
||||
"github.com/zitadel/oidc/example/server/storage"
|
||||
"github.com/zitadel/oidc/pkg/op"
|
||||
)
|
||||
|
||||
const (
|
||||
pathLoggedOut = "/logged-out"
|
||||
)
|
||||
|
||||
type Storage interface {
|
||||
op.Storage
|
||||
authenticate
|
||||
deviceAuthenticate
|
||||
func init() {
|
||||
storage.RegisterClients(
|
||||
storage.NativeClient("native"),
|
||||
storage.WebClient("web", "secret"),
|
||||
storage.WebClient("api", "secret"),
|
||||
)
|
||||
}
|
||||
|
||||
// simple counter for request IDs
|
||||
var counter atomic.Int64
|
||||
type Storage interface {
|
||||
op.Storage
|
||||
CheckUsernamePassword(username, password, id string) error
|
||||
}
|
||||
|
||||
// SetupServer creates an OIDC server with Issuer=http://localhost:<port>
|
||||
//
|
||||
// Use one of the pre-made clients in storage/clients.go or register a new one.
|
||||
func SetupServer(issuer string, storage Storage, logger *slog.Logger, wrapServer bool, extraOptions ...op.Option) chi.Router {
|
||||
func SetupServer(ctx context.Context, issuer string, storage Storage) *mux.Router {
|
||||
// this will allow us to use an issuer with http:// instead of https://
|
||||
os.Setenv(op.OidcDevMode, "true")
|
||||
|
||||
// the OpenID Provider requires a 32-byte key for (token) encryption
|
||||
// be sure to create a proper crypto random key and manage it securely!
|
||||
key := sha256.Sum256([]byte("test"))
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(logging.Middleware(
|
||||
logging.WithLogger(logger),
|
||||
logging.WithIDFunc(func() slog.Attr {
|
||||
return slog.Int64("id", counter.Add(1))
|
||||
}),
|
||||
))
|
||||
router := mux.NewRouter()
|
||||
|
||||
// for simplicity, we provide a very small default page for users who have signed out
|
||||
router.HandleFunc(pathLoggedOut, func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Write([]byte("signed out successfully"))
|
||||
// no need to check/log error, this will be handled by the middleware.
|
||||
_, err := w.Write([]byte("signed out successfully"))
|
||||
if err != nil {
|
||||
log.Printf("error serving logged out page: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// creation of the OpenIDProvider with the just created in-memory Storage
|
||||
provider, err := newOP(storage, issuer, key, logger, extraOptions...)
|
||||
provider, err := newOP(ctx, storage, issuer, key)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// the provider will only take care of the OpenID Protocol, so there must be some sort of UI for the login process
|
||||
// for the simplicity of the example this means a simple page with username and password field
|
||||
//be sure to provide an IssuerInterceptor with the IssuerFromRequest from the OP so the login can select / and pass it to the storage
|
||||
l := NewLogin(storage, op.AuthCallbackURL(provider), op.NewIssuerInterceptor(provider.IssuerFromRequest))
|
||||
l := NewLogin(storage, op.AuthCallbackURL(provider))
|
||||
|
||||
// regardless of how many pages / steps there are in the process, the UI must be registered in the router,
|
||||
// so we will direct all calls to /login to the login UI
|
||||
router.Mount("/login/", http.StripPrefix("/login", l.router))
|
||||
|
||||
router.Route("/device", func(r chi.Router) {
|
||||
registerDeviceAuth(storage, r)
|
||||
})
|
||||
|
||||
handler := http.Handler(provider)
|
||||
if wrapServer {
|
||||
handler = op.RegisterLegacyServer(op.NewLegacyServer(provider, *op.DefaultEndpoints), op.AuthorizeCallbackHandler(provider))
|
||||
}
|
||||
router.PathPrefix("/login/").Handler(http.StripPrefix("/login", l.router))
|
||||
|
||||
// we register the http handler of the OP on the root, so that the discovery endpoint (/.well-known/openid-configuration)
|
||||
// is served on the correct path
|
||||
//
|
||||
// if your issuer ends with a path (e.g. http://localhost:9998/custom/path/),
|
||||
// then you would have to set the path prefix (/custom/path/)
|
||||
router.Mount("/", handler)
|
||||
router.PathPrefix("/").Handler(provider.HttpHandler())
|
||||
|
||||
return router
|
||||
}
|
||||
|
@ -87,8 +79,9 @@ func SetupServer(issuer string, storage Storage, logger *slog.Logger, wrapServer
|
|||
// newOP will create an OpenID Provider for localhost on a specified port with a given encryption key
|
||||
// and a predefined default logout uri
|
||||
// it will enable all options (see descriptions)
|
||||
func newOP(storage op.Storage, issuer string, key [32]byte, logger *slog.Logger, extraOptions ...op.Option) (op.OpenIDProvider, error) {
|
||||
func newOP(ctx context.Context, storage op.Storage, issuer string, key [32]byte) (op.OpenIDProvider, error) {
|
||||
config := &op.Config{
|
||||
Issuer: issuer,
|
||||
CryptoKey: key,
|
||||
|
||||
// will be used if the end_session endpoint is called without a post_logout_redirect_uri
|
||||
|
@ -111,23 +104,10 @@ func newOP(storage op.Storage, issuer string, key [32]byte, logger *slog.Logger,
|
|||
|
||||
// this example has only static texts (in English), so we'll set the here accordingly
|
||||
SupportedUILocales: []language.Tag{language.English},
|
||||
|
||||
DeviceAuthorization: op.DeviceAuthorizationConfig{
|
||||
Lifetime: 5 * time.Minute,
|
||||
PollInterval: 5 * time.Second,
|
||||
UserFormPath: "/device",
|
||||
UserCode: op.UserCodeBase20,
|
||||
},
|
||||
}
|
||||
handler, err := op.NewOpenIDProvider(issuer, config, storage,
|
||||
append([]op.Option{
|
||||
//we must explicitly allow the use of the http issuer
|
||||
op.WithAllowInsecure(),
|
||||
handler, err := op.NewOpenIDProvider(ctx, config, storage,
|
||||
// as an example on how to customize an endpoint this will change the authorization_endpoint from /authorize to /auth
|
||||
op.WithCustomAuthEndpoint(op.NewEndpoint("auth")),
|
||||
// Pass our logger to the OP
|
||||
op.WithLogger(logger.WithGroup("op")),
|
||||
}, extraOptions...)...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
package exampleop
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed templates
|
||||
templateFS embed.FS
|
||||
templates = template.Must(template.ParseFS(templateFS, "templates/*.html"))
|
||||
)
|
||||
|
||||
const (
|
||||
queryAuthRequestID = "authRequestID"
|
||||
)
|
||||
|
||||
func errMsg(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
logrus.Error(err)
|
||||
return err.Error()
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
{{ define "confirm_device" -}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Confirm device authorization</title>
|
||||
<style>
|
||||
.green{
|
||||
background-color: green
|
||||
}
|
||||
.red{
|
||||
background-color: red
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Welcome back {{.Username}}!</h1>
|
||||
<p>
|
||||
You are about to grant device {{.ClientID}} access to the following scopes: {{.Scopes}}.
|
||||
</p>
|
||||
<button onclick="location.href='./confirm?action=allowed'" type="button" class="green">Allow</button>
|
||||
<button onclick="location.href='./confirm?action=denied'" type="button" class="red">Deny</button>
|
||||
</body>
|
||||
</html>
|
||||
{{- end }}
|
|
@ -1,29 +0,0 @@
|
|||
{{ define "device_login" -}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Login</title>
|
||||
</head>
|
||||
<body style="display: flex; align-items: center; justify-content: center; height: 100vh;">
|
||||
<form method="POST" action="/device/login" style="height: 200px; width: 200px;">
|
||||
|
||||
<input type="hidden" name="user_code" value="{{.UserCode}}">
|
||||
|
||||
<div>
|
||||
<label for="username">Username:</label>
|
||||
<input id="username" name="username" style="width: 100%">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password">Password:</label>
|
||||
<input id="password" name="password" style="width: 100%">
|
||||
</div>
|
||||
|
||||
<p style="color:red; min-height: 1rem;">{{.Error}}</p>
|
||||
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
{{- end }}
|
|
@ -1,29 +0,0 @@
|
|||
{{ define "login" -}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Login</title>
|
||||
</head>
|
||||
<body style="display: flex; align-items: center; justify-content: center; height: 100vh;">
|
||||
<form method="POST" action="/login/username" style="height: 200px; width: 200px;">
|
||||
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
|
||||
<div>
|
||||
<label for="username">Username:</label>
|
||||
<input id="username" name="username" style="width: 100%">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password">Password:</label>
|
||||
<input id="password" name="password" style="width: 100%">
|
||||
</div>
|
||||
|
||||
<p style="color:red; min-height: 1rem;">{{.Error}}</p>
|
||||
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>`
|
||||
{{- end }}
|
|
@ -1,21 +0,0 @@
|
|||
{{ define "usercode" -}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Device authorization</title>
|
||||
</head>
|
||||
<body style="display: flex; align-items: center; justify-content: center; height: 100vh;">
|
||||
<form method="POST" style="height: 200px; width: 200px;">
|
||||
<h1>Device authorization</h1>
|
||||
<div>
|
||||
<label for="user_code">Code:</label>
|
||||
<input id="user_code" name="user_code" style="width: 100%">
|
||||
</div>
|
||||
<p style="color:red; min-height: 1rem;">{{.Error}}</p>
|
||||
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
{{- end }}
|
|
@ -1,59 +1,34 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/example/server/config"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/example/server/exampleop"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/example/server/storage"
|
||||
"github.com/zitadel/oidc/example/server/exampleop"
|
||||
"github.com/zitadel/oidc/example/server/storage"
|
||||
)
|
||||
|
||||
func getUserStore(cfg *config.Config) (storage.UserStore, error) {
|
||||
if cfg.UsersFile == "" {
|
||||
return storage.NewUserStore(fmt.Sprintf("http://localhost:%s/", cfg.Port)), nil
|
||||
}
|
||||
return storage.StoreFromFile(cfg.UsersFile)
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := config.FromEnvVars(&config.Config{Port: "9998"})
|
||||
logger := slog.New(
|
||||
slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
AddSource: true,
|
||||
Level: slog.LevelDebug,
|
||||
}),
|
||||
)
|
||||
|
||||
//which gives us the issuer: http://localhost:9998/
|
||||
issuer := fmt.Sprintf("http://localhost:%s/", cfg.Port)
|
||||
|
||||
storage.RegisterClients(
|
||||
storage.NativeClient("native", cfg.RedirectURI...),
|
||||
storage.WebClient("web", "secret", cfg.RedirectURI...),
|
||||
storage.WebClient("api", "secret", cfg.RedirectURI...),
|
||||
)
|
||||
ctx := context.Background()
|
||||
|
||||
// the OpenIDProvider interface needs a Storage interface handling various checks and state manipulations
|
||||
// this might be the layer for accessing your database
|
||||
// in this example it will be handled in-memory
|
||||
store, err := getUserStore(cfg)
|
||||
if err != nil {
|
||||
logger.Error("cannot create UserStore", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
storage := storage.NewStorage(store)
|
||||
router := exampleop.SetupServer(issuer, storage, logger, false)
|
||||
storage := storage.NewStorage(storage.NewUserStore())
|
||||
|
||||
port := "9998"
|
||||
router := exampleop.SetupServer(ctx, "http://localhost:"+port, storage)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Addr: ":" + port,
|
||||
Handler: router,
|
||||
}
|
||||
logger.Info("server listening, press ctrl+c to stop", "addr", issuer)
|
||||
if server.ListenAndServe() != http.ErrServerClosed {
|
||||
logger.Error("server terminated", "error", err)
|
||||
os.Exit(1)
|
||||
log.Printf("server listening on http://localhost:%s/", port)
|
||||
log.Println("press ctrl+c to stop")
|
||||
err := server.ListenAndServe()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
<-ctx.Done()
|
||||
}
|
||||
|
|
|
@ -3,8 +3,8 @@ package storage
|
|||
import (
|
||||
"time"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/op"
|
||||
"github.com/zitadel/oidc/pkg/oidc"
|
||||
"github.com/zitadel/oidc/pkg/op"
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -32,8 +32,6 @@ type Client struct {
|
|||
devMode bool
|
||||
idTokenUserinfoClaimsAssertion bool
|
||||
clockSkew time.Duration
|
||||
postLogoutRedirectURIGlobs []string
|
||||
redirectURIGlobs []string
|
||||
}
|
||||
|
||||
// GetID must return the client_id
|
||||
|
@ -46,11 +44,21 @@ func (c *Client) RedirectURIs() []string {
|
|||
return c.redirectURIs
|
||||
}
|
||||
|
||||
// RedirectURIGlobs provide wildcarding for additional valid redirects
|
||||
func (c *Client) RedirectURIGlobs() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PostLogoutRedirectURIs must return the registered post_logout_redirect_uris for sign-outs
|
||||
func (c *Client) PostLogoutRedirectURIs() []string {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// PostLogoutRedirectURIGlobs provide extra wildcarding for additional valid redirects
|
||||
func (c *Client) PostLogoutRedirectURIGlobs() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplicationType must return the type of the client (app, native, user agent)
|
||||
func (c *Client) ApplicationType() op.ApplicationType {
|
||||
return c.applicationType
|
||||
|
@ -160,7 +168,7 @@ func NativeClient(id string, redirectURIs ...string) *Client {
|
|||
loginURL: defaultLoginURL,
|
||||
responseTypes: []oidc.ResponseType{oidc.ResponseTypeCode},
|
||||
grantTypes: []oidc.GrantType{oidc.GrantTypeCode, oidc.GrantTypeRefreshToken},
|
||||
accessTokenType: op.AccessTokenTypeBearer,
|
||||
accessTokenType: 0,
|
||||
devMode: false,
|
||||
idTokenUserinfoClaimsAssertion: false,
|
||||
clockSkew: 0,
|
||||
|
@ -184,52 +192,11 @@ func WebClient(id, secret string, redirectURIs ...string) *Client {
|
|||
applicationType: op.ApplicationTypeWeb,
|
||||
authMethod: oidc.AuthMethodBasic,
|
||||
loginURL: defaultLoginURL,
|
||||
responseTypes: []oidc.ResponseType{oidc.ResponseTypeCode, oidc.ResponseTypeIDTokenOnly, oidc.ResponseTypeIDToken},
|
||||
grantTypes: []oidc.GrantType{oidc.GrantTypeCode, oidc.GrantTypeRefreshToken, oidc.GrantTypeTokenExchange},
|
||||
accessTokenType: op.AccessTokenTypeBearer,
|
||||
devMode: true,
|
||||
idTokenUserinfoClaimsAssertion: false,
|
||||
clockSkew: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// DeviceClient creates a device client with Basic authentication.
|
||||
func DeviceClient(id, secret string) *Client {
|
||||
return &Client{
|
||||
id: id,
|
||||
secret: secret,
|
||||
redirectURIs: nil,
|
||||
applicationType: op.ApplicationTypeWeb,
|
||||
authMethod: oidc.AuthMethodBasic,
|
||||
loginURL: defaultLoginURL,
|
||||
responseTypes: []oidc.ResponseType{oidc.ResponseTypeCode},
|
||||
grantTypes: []oidc.GrantType{oidc.GrantTypeDeviceCode},
|
||||
accessTokenType: op.AccessTokenTypeBearer,
|
||||
grantTypes: []oidc.GrantType{oidc.GrantTypeCode, oidc.GrantTypeRefreshToken},
|
||||
accessTokenType: 0,
|
||||
devMode: false,
|
||||
idTokenUserinfoClaimsAssertion: false,
|
||||
clockSkew: 0,
|
||||
}
|
||||
}
|
||||
|
||||
type hasRedirectGlobs struct {
|
||||
*Client
|
||||
}
|
||||
|
||||
// RedirectURIGlobs provide wildcarding for additional valid redirects
|
||||
func (c hasRedirectGlobs) RedirectURIGlobs() []string {
|
||||
return c.redirectURIGlobs
|
||||
}
|
||||
|
||||
// PostLogoutRedirectURIGlobs provide extra wildcarding for additional valid redirects
|
||||
func (c hasRedirectGlobs) PostLogoutRedirectURIGlobs() []string {
|
||||
return c.postLogoutRedirectURIGlobs
|
||||
}
|
||||
|
||||
// RedirectGlobsClient wraps the client in a op.HasRedirectGlobs
|
||||
// only if DevMode is enabled.
|
||||
func RedirectGlobsClient(client *Client) op.Client {
|
||||
if client.devMode {
|
||||
return hasRedirectGlobs{client}
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
package storage
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/op"
|
||||
"github.com/zitadel/oidc/pkg/op"
|
||||
|
||||
"github.com/zitadel/oidc/pkg/oidc"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -17,9 +17,6 @@ const (
|
|||
|
||||
// CustomClaim is an example for how to return custom claims with this library
|
||||
CustomClaim = "custom_claim"
|
||||
|
||||
// CustomScopeImpersonatePrefix is an example scope prefix for passing user id to impersonate using token exchage
|
||||
CustomScopeImpersonatePrefix = "custom_scope:impersonate:"
|
||||
)
|
||||
|
||||
type AuthRequest struct {
|
||||
|
@ -35,27 +32,13 @@ type AuthRequest struct {
|
|||
UserID string
|
||||
Scopes []string
|
||||
ResponseType oidc.ResponseType
|
||||
ResponseMode oidc.ResponseMode
|
||||
Nonce string
|
||||
CodeChallenge *OIDCCodeChallenge
|
||||
|
||||
done bool
|
||||
passwordChecked bool
|
||||
authTime time.Time
|
||||
}
|
||||
|
||||
// LogValue allows you to define which fields will be logged.
|
||||
// Implements the [slog.LogValuer]
|
||||
func (a *AuthRequest) LogValue() slog.Value {
|
||||
return slog.GroupValue(
|
||||
slog.String("id", a.ID),
|
||||
slog.Time("creation_date", a.CreationDate),
|
||||
slog.Any("scopes", a.Scopes),
|
||||
slog.String("response_type", string(a.ResponseType)),
|
||||
slog.String("app_id", a.ApplicationID),
|
||||
slog.String("callback_uri", a.CallbackURI),
|
||||
)
|
||||
}
|
||||
|
||||
func (a *AuthRequest) GetID() string {
|
||||
return a.ID
|
||||
}
|
||||
|
@ -66,7 +49,7 @@ func (a *AuthRequest) GetACR() string {
|
|||
|
||||
func (a *AuthRequest) GetAMR() []string {
|
||||
// this example only uses password for authentication
|
||||
if a.done {
|
||||
if a.passwordChecked {
|
||||
return []string{"pwd"}
|
||||
}
|
||||
return nil
|
||||
|
@ -101,7 +84,7 @@ func (a *AuthRequest) GetResponseType() oidc.ResponseType {
|
|||
}
|
||||
|
||||
func (a *AuthRequest) GetResponseMode() oidc.ResponseMode {
|
||||
return a.ResponseMode
|
||||
return "" // we won't handle response mode in this example
|
||||
}
|
||||
|
||||
func (a *AuthRequest) GetScopes() []string {
|
||||
|
@ -117,11 +100,11 @@ func (a *AuthRequest) GetSubject() string {
|
|||
}
|
||||
|
||||
func (a *AuthRequest) Done() bool {
|
||||
return a.done
|
||||
return a.passwordChecked // this example only uses password for authentication
|
||||
}
|
||||
|
||||
func PromptToInternal(oidcPrompt oidc.SpaceDelimitedArray) []string {
|
||||
prompts := make([]string, 0, len(oidcPrompt))
|
||||
prompts := make([]string, len(oidcPrompt))
|
||||
for _, oidcPrompt := range oidcPrompt {
|
||||
switch oidcPrompt {
|
||||
case oidc.PromptNone,
|
||||
|
@ -155,7 +138,6 @@ func authRequestToInternal(authReq *oidc.AuthRequest, userID string) *AuthReques
|
|||
UserID: userID,
|
||||
Scopes: authReq.Scopes,
|
||||
ResponseType: authReq.ResponseType,
|
||||
ResponseMode: authReq.ResponseMode,
|
||||
Nonce: authReq.Nonce,
|
||||
CodeChallenge: &OIDCCodeChallenge{
|
||||
Challenge: authReq.CodeChallenge,
|
||||
|
@ -164,15 +146,6 @@ func authRequestToInternal(authReq *oidc.AuthRequest, userID string) *AuthReques
|
|||
}
|
||||
}
|
||||
|
||||
type AuthRequestWithSessionState struct {
|
||||
*AuthRequest
|
||||
SessionState string
|
||||
}
|
||||
|
||||
func (a *AuthRequestWithSessionState) GetSessionState() string {
|
||||
return a.SessionState
|
||||
}
|
||||
|
||||
type OIDCCodeChallenge struct {
|
||||
Challenge string
|
||||
Method string
|
||||
|
|
|
@ -4,18 +4,16 @@ import (
|
|||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
jose "github.com/go-jose/go-jose/v4"
|
||||
"github.com/google/uuid"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/op"
|
||||
"github.com/zitadel/oidc/pkg/oidc"
|
||||
"github.com/zitadel/oidc/pkg/op"
|
||||
)
|
||||
|
||||
// serviceKey1 is a public key which will be used for the JWT Profile Authorization Grant
|
||||
|
@ -28,10 +26,8 @@ var serviceKey1 = &rsa.PublicKey{
|
|||
E: 65537,
|
||||
}
|
||||
|
||||
var (
|
||||
_ op.Storage = &Storage{}
|
||||
_ op.ClientCredentialsStorage = &Storage{}
|
||||
)
|
||||
// var _ op.Storage = &storage{}
|
||||
// var _ op.ClientCredentialsStorage = &storage{}
|
||||
|
||||
// storage implements the op.Storage interface
|
||||
// typically you would implement this as a layer on top of your database
|
||||
|
@ -46,54 +42,15 @@ type Storage struct {
|
|||
services map[string]Service
|
||||
refreshTokens map[string]*RefreshToken
|
||||
signingKey signingKey
|
||||
deviceCodes map[string]deviceAuthorizationEntry
|
||||
userCodes map[string]string
|
||||
serviceUsers map[string]*Client
|
||||
}
|
||||
|
||||
type signingKey struct {
|
||||
id string
|
||||
algorithm jose.SignatureAlgorithm
|
||||
key *rsa.PrivateKey
|
||||
}
|
||||
|
||||
func (s *signingKey) SignatureAlgorithm() jose.SignatureAlgorithm {
|
||||
return s.algorithm
|
||||
}
|
||||
|
||||
func (s *signingKey) Key() any {
|
||||
return s.key
|
||||
}
|
||||
|
||||
func (s *signingKey) ID() string {
|
||||
return s.id
|
||||
}
|
||||
|
||||
type publicKey struct {
|
||||
signingKey
|
||||
}
|
||||
|
||||
func (s *publicKey) ID() string {
|
||||
return s.id
|
||||
}
|
||||
|
||||
func (s *publicKey) Algorithm() jose.SignatureAlgorithm {
|
||||
return s.algorithm
|
||||
}
|
||||
|
||||
func (s *publicKey) Use() string {
|
||||
return "sig"
|
||||
}
|
||||
|
||||
func (s *publicKey) Key() any {
|
||||
return &s.key.PublicKey
|
||||
ID string
|
||||
Algorithm string
|
||||
Key *rsa.PrivateKey
|
||||
}
|
||||
|
||||
func NewStorage(userStore UserStore) *Storage {
|
||||
return NewStorageWithClients(userStore, clients)
|
||||
}
|
||||
|
||||
func NewStorageWithClients(userStore UserStore, clients map[string]*Client) *Storage {
|
||||
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
return &Storage{
|
||||
authRequests: make(map[string]*AuthRequest),
|
||||
|
@ -110,21 +67,9 @@ func NewStorageWithClients(userStore UserStore, clients map[string]*Client) *Sto
|
|||
},
|
||||
},
|
||||
signingKey: signingKey{
|
||||
id: uuid.NewString(),
|
||||
algorithm: jose.RS256,
|
||||
key: key,
|
||||
},
|
||||
deviceCodes: make(map[string]deviceAuthorizationEntry),
|
||||
userCodes: make(map[string]string),
|
||||
serviceUsers: map[string]*Client{
|
||||
"sid1": {
|
||||
id: "sid1",
|
||||
secret: "verysecret",
|
||||
grantTypes: []oidc.GrantType{
|
||||
oidc.GrantTypeClientCredentials,
|
||||
},
|
||||
accessTokenType: op.AccessTokenTypeBearer,
|
||||
},
|
||||
ID: "id",
|
||||
Algorithm: "RS256",
|
||||
Key: key,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -150,21 +95,7 @@ func (s *Storage) CheckUsernamePassword(username, password, id string) error {
|
|||
// you will have to change some state on the request to guide the user through possible multiple steps of the login process
|
||||
// in this example we'll simply check the username / password and set a boolean to true
|
||||
// therefore we will also just check this boolean if the request / login has been finished
|
||||
request.done = true
|
||||
|
||||
request.authTime = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("username or password wrong")
|
||||
}
|
||||
|
||||
func (s *Storage) CheckUsernamePasswordSimple(username, password string) error {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
user := s.userStore.GetUserByUsername(username)
|
||||
if user != nil && user.Password == password {
|
||||
request.passwordChecked = true
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("username or password wrong")
|
||||
|
@ -176,12 +107,6 @@ func (s *Storage) CreateAuthRequest(ctx context.Context, authReq *oidc.AuthReque
|
|||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
if len(authReq.Prompt) == 1 && authReq.Prompt[0] == "none" {
|
||||
// With prompt=none, there is no way for the user to log in
|
||||
// so return error right away.
|
||||
return nil, oidc.ErrLoginRequired()
|
||||
}
|
||||
|
||||
// typically, you'll fill your storage / storage model with the information of the passed object
|
||||
request := authRequestToInternal(authReq, userID)
|
||||
|
||||
|
@ -256,14 +181,11 @@ func (s *Storage) DeleteAuthRequest(ctx context.Context, id string) error {
|
|||
// it will be called for all requests able to return an access token (Authorization Code Flow, Implicit Flow, JWT Profile, ...)
|
||||
func (s *Storage) CreateAccessToken(ctx context.Context, request op.TokenRequest) (string, time.Time, error) {
|
||||
var applicationID string
|
||||
switch req := request.(type) {
|
||||
case *AuthRequest:
|
||||
// if authenticated for an app (auth code / implicit flow) we must save the client_id to the token
|
||||
applicationID = req.ApplicationID
|
||||
case op.TokenExchangeRequest:
|
||||
applicationID = req.GetClientID()
|
||||
authReq, ok := request.(*AuthRequest)
|
||||
if ok {
|
||||
applicationID = authReq.ApplicationID
|
||||
}
|
||||
|
||||
token, err := s.accessToken(applicationID, "", request.GetSubject(), request.GetAudience(), request.GetScopes())
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
|
@ -274,11 +196,6 @@ func (s *Storage) CreateAccessToken(ctx context.Context, request op.TokenRequest
|
|||
// CreateAccessAndRefreshTokens implements the op.Storage interface
|
||||
// it will be called for all requests able to return an access and refresh token (Authorization Code Flow, Refresh Token Request)
|
||||
func (s *Storage) CreateAccessAndRefreshTokens(ctx context.Context, request op.TokenRequest, currentRefreshToken string) (accessTokenID string, newRefreshToken string, expiration time.Time, err error) {
|
||||
// generate tokens via token exchange flow if request is relevant
|
||||
if teReq, ok := request.(op.TokenExchangeRequest); ok {
|
||||
return s.exchangeRefreshToken(ctx, teReq)
|
||||
}
|
||||
|
||||
// get the information depending on the request type / implementation
|
||||
applicationID, authTime, amr := getInfoFromRequest(request)
|
||||
|
||||
|
@ -298,36 +215,14 @@ func (s *Storage) CreateAccessAndRefreshTokens(ctx context.Context, request op.T
|
|||
|
||||
// if we get here, the currentRefreshToken was not empty, so the call is a refresh token request
|
||||
// we therefore will have to check the currentRefreshToken and renew the refresh token
|
||||
|
||||
newRefreshToken = uuid.NewString()
|
||||
|
||||
accessToken, err := s.accessToken(applicationID, newRefreshToken, request.GetSubject(), request.GetAudience(), request.GetScopes())
|
||||
refreshToken, refreshTokenID, err := s.renewRefreshToken(currentRefreshToken)
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
|
||||
if err := s.renewRefreshToken(currentRefreshToken, newRefreshToken, accessToken.ID); err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
|
||||
return accessToken.ID, newRefreshToken, accessToken.Expiration, nil
|
||||
}
|
||||
|
||||
func (s *Storage) exchangeRefreshToken(ctx context.Context, request op.TokenExchangeRequest) (accessTokenID string, newRefreshToken string, expiration time.Time, err error) {
|
||||
applicationID := request.GetClientID()
|
||||
authTime := request.GetAuthTime()
|
||||
|
||||
refreshTokenID := uuid.NewString()
|
||||
accessToken, err := s.accessToken(applicationID, refreshTokenID, request.GetSubject(), request.GetAudience(), request.GetScopes())
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
|
||||
refreshToken, err := s.createRefreshToken(accessToken, nil, authTime)
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
|
||||
return accessToken.ID, refreshToken, accessToken.Expiration, nil
|
||||
}
|
||||
|
||||
|
@ -357,16 +252,6 @@ func (s *Storage) TerminateSession(ctx context.Context, userID string, clientID
|
|||
return nil
|
||||
}
|
||||
|
||||
// GetRefreshTokenInfo looks up a refresh token and returns the token id and user id.
|
||||
// If given something that is not a refresh token, it must return error.
|
||||
func (s *Storage) GetRefreshTokenInfo(ctx context.Context, clientID string, token string) (userID string, tokenID string, err error) {
|
||||
refreshToken, ok := s.refreshTokens[token]
|
||||
if !ok {
|
||||
return "", "", op.ErrInvalidRefreshToken
|
||||
}
|
||||
return refreshToken.UserID, refreshToken.ID, nil
|
||||
}
|
||||
|
||||
// RevokeToken implements the op.Storage interface
|
||||
// it will be called after parsing and validation of the token revocation request
|
||||
func (s *Storage) RevokeToken(ctx context.Context, tokenIDOrToken string, userID string, clientID string) *oidc.Error {
|
||||
|
@ -392,35 +277,52 @@ func (s *Storage) RevokeToken(ctx context.Context, tokenIDOrToken string, userID
|
|||
if refreshToken.ApplicationID != clientID {
|
||||
return oidc.ErrInvalidClient().WithDescription("token was not issued for this client")
|
||||
}
|
||||
delete(s.refreshTokens, refreshToken.ID)
|
||||
// if it is a refresh token, you will have to remove the access token as well
|
||||
delete(s.tokens, refreshToken.AccessToken)
|
||||
delete(s.refreshTokens, refreshToken.ID)
|
||||
for _, accessToken := range s.tokens {
|
||||
if accessToken.RefreshTokenID == refreshToken.ID {
|
||||
delete(s.tokens, accessToken.ID)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SigningKey implements the op.Storage interface
|
||||
// GetSigningKey implements the op.Storage interface
|
||||
// it will be called when creating the OpenID Provider
|
||||
func (s *Storage) SigningKey(ctx context.Context) (op.SigningKey, error) {
|
||||
func (s *Storage) GetSigningKey(ctx context.Context, keyCh chan<- jose.SigningKey) {
|
||||
// in this example the signing key is a static rsa.PrivateKey and the algorithm used is RS256
|
||||
// you would obviously have a more complex implementation and store / retrieve the key from your database as well
|
||||
return &s.signingKey, nil
|
||||
//
|
||||
// the idea of the signing key channel is, that you can (with what ever mechanism) rotate your signing key and
|
||||
// switch the key of the signer via this channel
|
||||
keyCh <- jose.SigningKey{
|
||||
Algorithm: jose.SignatureAlgorithm(s.signingKey.Algorithm), // always tell the signer with algorithm to use
|
||||
Key: jose.JSONWebKey{
|
||||
KeyID: s.signingKey.ID, // always give the key an id so, that it will include it in the token header as `kid` claim
|
||||
Key: s.signingKey.Key,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SignatureAlgorithms implements the op.Storage interface
|
||||
// it will be called to get the sign
|
||||
func (s *Storage) SignatureAlgorithms(context.Context) ([]jose.SignatureAlgorithm, error) {
|
||||
return []jose.SignatureAlgorithm{s.signingKey.algorithm}, nil
|
||||
}
|
||||
|
||||
// KeySet implements the op.Storage interface
|
||||
// GetKeySet implements the op.Storage interface
|
||||
// it will be called to get the current (public) keys, among others for the keys_endpoint or for validating access_tokens on the userinfo_endpoint, ...
|
||||
func (s *Storage) KeySet(ctx context.Context) ([]op.Key, error) {
|
||||
func (s *Storage) GetKeySet(ctx context.Context) (*jose.JSONWebKeySet, error) {
|
||||
// as mentioned above, this example only has a single signing key without key rotation,
|
||||
// so it will directly use its public key
|
||||
//
|
||||
// when using key rotation you typically would store the public keys alongside the private keys in your database
|
||||
// and give both of them an expiration date, with the public key having a longer lifetime
|
||||
return []op.Key{&publicKey{s.signingKey}}, nil
|
||||
// and give both of them an expiration date, with the public key having a longer lifetime (e.g. rotate private key every
|
||||
return &jose.JSONWebKeySet{
|
||||
Keys: []jose.JSONWebKey{
|
||||
{
|
||||
KeyID: s.signingKey.ID,
|
||||
Algorithm: s.signingKey.Algorithm,
|
||||
Use: oidc.KeyUseSignature,
|
||||
Key: &s.signingKey.Key.PublicKey,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetClientByClientID implements the op.Storage interface
|
||||
|
@ -432,7 +334,7 @@ func (s *Storage) GetClientByClientID(ctx context.Context, clientID string) (op.
|
|||
if !ok {
|
||||
return nil, fmt.Errorf("client not found")
|
||||
}
|
||||
return RedirectGlobsClient(client), nil
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// AuthorizeClientIDSecret implements the op.Storage interface
|
||||
|
@ -452,22 +354,15 @@ func (s *Storage) AuthorizeClientIDSecret(ctx context.Context, clientID, clientS
|
|||
return nil
|
||||
}
|
||||
|
||||
// SetUserinfoFromScopes implements the op.Storage interface.
|
||||
// Provide an empty implementation and use SetUserinfoFromRequest instead.
|
||||
func (s *Storage) SetUserinfoFromScopes(ctx context.Context, userinfo *oidc.UserInfo, userID, clientID string, scopes []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetUserinfoFromRequests implements the op.CanSetUserinfoFromRequest interface. In the
|
||||
// next major release, it will be required for op.Storage.
|
||||
// It will be called for the creation of an id_token, so we'll just pass it to the private function without any further check
|
||||
func (s *Storage) SetUserinfoFromRequest(ctx context.Context, userinfo *oidc.UserInfo, token op.IDTokenRequest, scopes []string) error {
|
||||
return s.setUserinfo(ctx, userinfo, token.GetSubject(), token.GetClientID(), scopes)
|
||||
// SetUserinfoFromScopes implements the op.Storage interface
|
||||
// it will be called for the creation of an id_token, so we'll just pass it to the private function without any further check
|
||||
func (s *Storage) SetUserinfoFromScopes(ctx context.Context, userinfo oidc.UserInfoSetter, userID, clientID string, scopes []string) error {
|
||||
return s.setUserinfo(ctx, userinfo, userID, clientID, scopes)
|
||||
}
|
||||
|
||||
// SetUserinfoFromToken implements the op.Storage interface
|
||||
// it will be called for the userinfo endpoint, so we read the token and pass the information from that to the private function
|
||||
func (s *Storage) SetUserinfoFromToken(ctx context.Context, userinfo *oidc.UserInfo, tokenID, subject, origin string) error {
|
||||
func (s *Storage) SetUserinfoFromToken(ctx context.Context, userinfo oidc.UserInfoSetter, tokenID, subject, origin string) error {
|
||||
token, ok := func() (*Token, bool) {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
@ -490,15 +385,12 @@ func (s *Storage) SetUserinfoFromToken(ctx context.Context, userinfo *oidc.UserI
|
|||
// return err
|
||||
// }
|
||||
//}
|
||||
if token.Expiration.Before(time.Now()) {
|
||||
return fmt.Errorf("token is expired")
|
||||
}
|
||||
return s.setUserinfo(ctx, userinfo, token.Subject, token.ApplicationID, token.Scopes)
|
||||
}
|
||||
|
||||
// SetIntrospectionFromToken implements the op.Storage interface
|
||||
// it will be called for the introspection endpoint, so we read the token and pass the information from that to the private function
|
||||
func (s *Storage) SetIntrospectionFromToken(ctx context.Context, introspection *oidc.IntrospectionResponse, tokenID, subject, clientID string) error {
|
||||
func (s *Storage) SetIntrospectionFromToken(ctx context.Context, introspection oidc.IntrospectionResponse, tokenID, subject, clientID string) error {
|
||||
token, ok := func() (*Token, bool) {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
@ -515,17 +407,14 @@ func (s *Storage) SetIntrospectionFromToken(ctx context.Context, introspection *
|
|||
// this will automatically be done by the library if you don't return an error
|
||||
// you can also return further information about the user / associated token
|
||||
// e.g. the userinfo (equivalent to userinfo endpoint)
|
||||
|
||||
userInfo := new(oidc.UserInfo)
|
||||
err := s.setUserinfo(ctx, userInfo, subject, clientID, token.Scopes)
|
||||
err := s.setUserinfo(ctx, introspection, subject, clientID, token.Scopes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
introspection.SetUserInfo(userInfo)
|
||||
//...and also the requested scopes...
|
||||
introspection.Scope = token.Scopes
|
||||
introspection.SetScopes(token.Scopes)
|
||||
//...and the client the token was issued to
|
||||
introspection.ClientID = token.ApplicationID
|
||||
introspection.SetClientID(token.ApplicationID)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
@ -534,11 +423,7 @@ func (s *Storage) SetIntrospectionFromToken(ctx context.Context, introspection *
|
|||
|
||||
// GetPrivateClaimsFromScopes implements the op.Storage interface
|
||||
// it will be called for the creation of a JWT access token to assert claims for custom scopes
|
||||
func (s *Storage) GetPrivateClaimsFromScopes(ctx context.Context, userID, clientID string, scopes []string) (claims map[string]any, err error) {
|
||||
return s.getPrivateClaimsFromScopes(ctx, userID, clientID, scopes)
|
||||
}
|
||||
|
||||
func (s *Storage) getPrivateClaimsFromScopes(ctx context.Context, userID, clientID string, scopes []string) (claims map[string]any, err error) {
|
||||
func (s *Storage) GetPrivateClaimsFromScopes(ctx context.Context, userID, clientID string, scopes []string) (claims map[string]interface{}, err error) {
|
||||
for _, scope := range scopes {
|
||||
switch scope {
|
||||
case CustomScope:
|
||||
|
@ -548,9 +433,9 @@ func (s *Storage) getPrivateClaimsFromScopes(ctx context.Context, userID, client
|
|||
return claims, nil
|
||||
}
|
||||
|
||||
// GetKeyByIDAndClientID implements the op.Storage interface
|
||||
// GetKeyByIDAndUserID implements the op.Storage interface
|
||||
// it will be called to validate the signatures of a JWT (JWT Profile Grant and Authentication)
|
||||
func (s *Storage) GetKeyByIDAndClientID(ctx context.Context, keyID, clientID string) (*jose.JSONWebKey, error) {
|
||||
func (s *Storage) GetKeyByIDAndUserID(ctx context.Context, keyID, clientID string) (*jose.JSONWebKey, error) {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
service, ok := s.services[clientID]
|
||||
|
@ -599,41 +484,33 @@ func (s *Storage) createRefreshToken(accessToken *Token, amr []string, authTime
|
|||
Audience: accessToken.Audience,
|
||||
Expiration: time.Now().Add(5 * time.Hour),
|
||||
Scopes: accessToken.Scopes,
|
||||
AccessToken: accessToken.ID,
|
||||
}
|
||||
s.refreshTokens[token.ID] = token
|
||||
return token.Token, nil
|
||||
}
|
||||
|
||||
// renewRefreshToken checks the provided refresh_token and creates a new one based on the current
|
||||
//
|
||||
// [Refresh Token Rotation] is implemented.
|
||||
//
|
||||
// [Refresh Token Rotation]: https://www.rfc-editor.org/rfc/rfc6819#section-5.2.2.3
|
||||
func (s *Storage) renewRefreshToken(currentRefreshToken, newRefreshToken, newAccessToken string) error {
|
||||
func (s *Storage) renewRefreshToken(currentRefreshToken string) (string, string, error) {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
refreshToken, ok := s.refreshTokens[currentRefreshToken]
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid refresh token")
|
||||
return "", "", fmt.Errorf("invalid refresh token")
|
||||
}
|
||||
// deletes the refresh token
|
||||
// deletes the refresh token and all access tokens which were issued based on this refresh token
|
||||
delete(s.refreshTokens, currentRefreshToken)
|
||||
|
||||
// delete the access token which was issued based on this refresh token
|
||||
delete(s.tokens, refreshToken.AccessToken)
|
||||
|
||||
if refreshToken.Expiration.Before(time.Now()) {
|
||||
return fmt.Errorf("expired refresh token")
|
||||
for _, token := range s.tokens {
|
||||
if token.RefreshTokenID == currentRefreshToken {
|
||||
delete(s.tokens, token.ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// creates a new refresh token based on the current one
|
||||
refreshToken.Token = newRefreshToken
|
||||
refreshToken.ID = newRefreshToken
|
||||
refreshToken.Expiration = time.Now().Add(5 * time.Hour)
|
||||
refreshToken.AccessToken = newAccessToken
|
||||
s.refreshTokens[newRefreshToken] = refreshToken
|
||||
return nil
|
||||
token := uuid.NewString()
|
||||
refreshToken.Token = token
|
||||
refreshToken.ID = token
|
||||
s.refreshTokens[token] = refreshToken
|
||||
return token, refreshToken.ID, nil
|
||||
}
|
||||
|
||||
// accessToken will store an access_token in-memory based on the provided information
|
||||
|
@ -654,7 +531,7 @@ func (s *Storage) accessToken(applicationID, refreshTokenID, subject string, aud
|
|||
}
|
||||
|
||||
// setUserinfo sets the info based on the user, scopes and if necessary the clientID
|
||||
func (s *Storage) setUserinfo(ctx context.Context, userInfo *oidc.UserInfo, userID, clientID string, scopes []string) (err error) {
|
||||
func (s *Storage) setUserinfo(ctx context.Context, userInfo oidc.UserInfoSetter, userID, clientID string, scopes []string) (err error) {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
user := s.userStore.GetUserByID(userID)
|
||||
|
@ -664,19 +541,17 @@ func (s *Storage) setUserinfo(ctx context.Context, userInfo *oidc.UserInfo, user
|
|||
for _, scope := range scopes {
|
||||
switch scope {
|
||||
case oidc.ScopeOpenID:
|
||||
userInfo.Subject = user.ID
|
||||
userInfo.SetSubject(user.ID)
|
||||
case oidc.ScopeEmail:
|
||||
userInfo.Email = user.Email
|
||||
userInfo.EmailVerified = oidc.Bool(user.EmailVerified)
|
||||
userInfo.SetEmail(user.Email, user.EmailVerified)
|
||||
case oidc.ScopeProfile:
|
||||
userInfo.PreferredUsername = user.Username
|
||||
userInfo.Name = user.FirstName + " " + user.LastName
|
||||
userInfo.FamilyName = user.LastName
|
||||
userInfo.GivenName = user.FirstName
|
||||
userInfo.Locale = oidc.NewLocale(user.PreferredLanguage)
|
||||
userInfo.SetPreferredUsername(user.Username)
|
||||
userInfo.SetName(user.FirstName + " " + user.LastName)
|
||||
userInfo.SetFamilyName(user.LastName)
|
||||
userInfo.SetGivenName(user.FirstName)
|
||||
userInfo.SetLocale(user.PreferredLanguage)
|
||||
case oidc.ScopePhone:
|
||||
userInfo.PhoneNumber = user.Phone
|
||||
userInfo.PhoneNumberVerified = user.PhoneVerified
|
||||
userInfo.SetPhone(user.Phone, user.PhoneVerified)
|
||||
case CustomScope:
|
||||
// you can also have a custom scope and assert public or custom claims based on that
|
||||
userInfo.AppendClaims(CustomClaim, customClaim(clientID))
|
||||
|
@ -685,101 +560,6 @@ func (s *Storage) setUserinfo(ctx context.Context, userInfo *oidc.UserInfo, user
|
|||
return nil
|
||||
}
|
||||
|
||||
// ValidateTokenExchangeRequest implements the op.TokenExchangeStorage interface
|
||||
// it will be called to validate parsed Token Exchange Grant request
|
||||
func (s *Storage) ValidateTokenExchangeRequest(ctx context.Context, request op.TokenExchangeRequest) error {
|
||||
if request.GetRequestedTokenType() == "" {
|
||||
request.SetRequestedTokenType(oidc.RefreshTokenType)
|
||||
}
|
||||
|
||||
// Just an example, some use cases might need this use case
|
||||
if request.GetExchangeSubjectTokenType() == oidc.IDTokenType && request.GetRequestedTokenType() == oidc.RefreshTokenType {
|
||||
return errors.New("exchanging id_token to refresh_token is not supported")
|
||||
}
|
||||
|
||||
// Check impersonation permissions
|
||||
if request.GetExchangeActor() == "" && !s.userStore.GetUserByID(request.GetExchangeSubject()).IsAdmin {
|
||||
return errors.New("user doesn't have impersonation permission")
|
||||
}
|
||||
|
||||
allowedScopes := make([]string, 0)
|
||||
for _, scope := range request.GetScopes() {
|
||||
if scope == oidc.ScopeAddress {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(scope, CustomScopeImpersonatePrefix) {
|
||||
subject := strings.TrimPrefix(scope, CustomScopeImpersonatePrefix)
|
||||
request.SetSubject(subject)
|
||||
}
|
||||
|
||||
allowedScopes = append(allowedScopes, scope)
|
||||
}
|
||||
|
||||
request.SetCurrentScopes(allowedScopes)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateTokenExchangeRequest implements the op.TokenExchangeStorage interface
|
||||
// Common use case is to store request for audit purposes. For this example we skip the storing.
|
||||
func (s *Storage) CreateTokenExchangeRequest(ctx context.Context, request op.TokenExchangeRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPrivateClaimsFromScopesForTokenExchange implements the op.TokenExchangeStorage interface
|
||||
// it will be called for the creation of an exchanged JWT access token to assert claims for custom scopes
|
||||
// plus adding token exchange specific claims related to delegation or impersonation
|
||||
func (s *Storage) GetPrivateClaimsFromTokenExchangeRequest(ctx context.Context, request op.TokenExchangeRequest) (claims map[string]any, err error) {
|
||||
claims, err = s.getPrivateClaimsFromScopes(ctx, "", request.GetClientID(), request.GetScopes())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for k, v := range s.getTokenExchangeClaims(ctx, request) {
|
||||
claims = appendClaim(claims, k, v)
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// SetUserinfoFromScopesForTokenExchange implements the op.TokenExchangeStorage interface
|
||||
// it will be called for the creation of an id_token - we are using the same private function as for other flows,
|
||||
// plus adding token exchange specific claims related to delegation or impersonation
|
||||
func (s *Storage) SetUserinfoFromTokenExchangeRequest(ctx context.Context, userinfo *oidc.UserInfo, request op.TokenExchangeRequest) error {
|
||||
err := s.setUserinfo(ctx, userinfo, request.GetSubject(), request.GetClientID(), request.GetScopes())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for k, v := range s.getTokenExchangeClaims(ctx, request) {
|
||||
userinfo.AppendClaims(k, v)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Storage) getTokenExchangeClaims(ctx context.Context, request op.TokenExchangeRequest) (claims map[string]any) {
|
||||
for _, scope := range request.GetScopes() {
|
||||
switch {
|
||||
case strings.HasPrefix(scope, CustomScopeImpersonatePrefix) && request.GetExchangeActor() == "":
|
||||
// Set actor subject claim for impersonation flow
|
||||
claims = appendClaim(claims, "act", map[string]any{
|
||||
"sub": request.GetExchangeSubject(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Set actor subject claim for delegation flow
|
||||
// if request.GetExchangeActor() != "" {
|
||||
// claims = appendClaim(claims, "act", map[string]any{
|
||||
// "sub": request.GetExchangeActor(),
|
||||
// })
|
||||
// }
|
||||
|
||||
return claims
|
||||
}
|
||||
|
||||
// getInfoFromRequest returns the clientID, authTime and amr depending on the op.TokenRequest type / implementation
|
||||
func getInfoFromRequest(req op.TokenRequest) (clientID string, authTime time.Time, amr []string) {
|
||||
authReq, ok := req.(*AuthRequest) // Code Flow (with scope offline_access)
|
||||
|
@ -794,140 +574,17 @@ func getInfoFromRequest(req op.TokenRequest) (clientID string, authTime time.Tim
|
|||
}
|
||||
|
||||
// customClaim demonstrates how to return custom claims based on provided information
|
||||
func customClaim(clientID string) map[string]any {
|
||||
return map[string]any{
|
||||
func customClaim(clientID string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"client": clientID,
|
||||
"other": "stuff",
|
||||
}
|
||||
}
|
||||
|
||||
func appendClaim(claims map[string]any, claim string, value any) map[string]any {
|
||||
func appendClaim(claims map[string]interface{}, claim string, value interface{}) map[string]interface{} {
|
||||
if claims == nil {
|
||||
claims = make(map[string]any)
|
||||
claims = make(map[string]interface{})
|
||||
}
|
||||
claims[claim] = value
|
||||
return claims
|
||||
}
|
||||
|
||||
type deviceAuthorizationEntry struct {
|
||||
deviceCode string
|
||||
userCode string
|
||||
state *op.DeviceAuthorizationState
|
||||
}
|
||||
|
||||
func (s *Storage) StoreDeviceAuthorization(ctx context.Context, clientID, deviceCode, userCode string, expires time.Time, scopes []string) error {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
if _, ok := s.clients[clientID]; !ok {
|
||||
return errors.New("client not found")
|
||||
}
|
||||
|
||||
if _, ok := s.userCodes[userCode]; ok {
|
||||
return op.ErrDuplicateUserCode
|
||||
}
|
||||
|
||||
s.deviceCodes[deviceCode] = deviceAuthorizationEntry{
|
||||
deviceCode: deviceCode,
|
||||
userCode: userCode,
|
||||
state: &op.DeviceAuthorizationState{
|
||||
ClientID: clientID,
|
||||
Scopes: scopes,
|
||||
Expires: expires,
|
||||
},
|
||||
}
|
||||
|
||||
s.userCodes[userCode] = deviceCode
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Storage) GetDeviceAuthorizatonState(ctx context.Context, clientID, deviceCode string) (*op.DeviceAuthorizationState, error) {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
entry, ok := s.deviceCodes[deviceCode]
|
||||
if !ok || entry.state.ClientID != clientID {
|
||||
return nil, errors.New("device code not found for client") // is there a standard not found error in the framework?
|
||||
}
|
||||
|
||||
return entry.state, nil
|
||||
}
|
||||
|
||||
func (s *Storage) GetDeviceAuthorizationByUserCode(ctx context.Context, userCode string) (*op.DeviceAuthorizationState, error) {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
entry, ok := s.deviceCodes[s.userCodes[userCode]]
|
||||
if !ok {
|
||||
return nil, errors.New("user code not found")
|
||||
}
|
||||
|
||||
return entry.state, nil
|
||||
}
|
||||
|
||||
func (s *Storage) CompleteDeviceAuthorization(ctx context.Context, userCode, subject string) error {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
entry, ok := s.deviceCodes[s.userCodes[userCode]]
|
||||
if !ok {
|
||||
return errors.New("user code not found")
|
||||
}
|
||||
|
||||
entry.state.Subject = subject
|
||||
entry.state.Done = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Storage) DenyDeviceAuthorization(ctx context.Context, userCode string) error {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
s.deviceCodes[s.userCodes[userCode]].state.Denied = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthRequestDone is used by testing and is not required to implement op.Storage
|
||||
func (s *Storage) AuthRequestDone(id string) error {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
if req, ok := s.authRequests[id]; ok {
|
||||
req.done = true
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New("request not found")
|
||||
}
|
||||
|
||||
func (s *Storage) ClientCredentials(ctx context.Context, clientID, clientSecret string) (op.Client, error) {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
client, ok := s.serviceUsers[clientID]
|
||||
if !ok {
|
||||
return nil, errors.New("wrong service user or password")
|
||||
}
|
||||
if client.secret != clientSecret {
|
||||
return nil, errors.New("wrong service user or password")
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (s *Storage) ClientCredentialsTokenRequest(ctx context.Context, clientID string, scopes []string) (op.TokenRequest, error) {
|
||||
client, ok := s.serviceUsers[clientID]
|
||||
if !ok {
|
||||
return nil, errors.New("wrong service user or password")
|
||||
}
|
||||
|
||||
return &oidc.JWTTokenRequest{
|
||||
Subject: client.id,
|
||||
Audience: []string{clientID},
|
||||
Scopes: scopes,
|
||||
}, nil
|
||||
}
|
||||
|
|
|
@ -1,281 +0,0 @@
|
|||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
jose "github.com/go-jose/go-jose/v4"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/op"
|
||||
)
|
||||
|
||||
type multiStorage struct {
|
||||
issuers map[string]*Storage
|
||||
}
|
||||
|
||||
// NewMultiStorage implements the op.Storage interface by wrapping multiple storage structs
|
||||
// and selecting them by the calling issuer
|
||||
func NewMultiStorage(issuers []string) *multiStorage {
|
||||
s := make(map[string]*Storage)
|
||||
for _, issuer := range issuers {
|
||||
s[issuer] = NewStorage(NewUserStore(issuer))
|
||||
}
|
||||
return &multiStorage{issuers: s}
|
||||
}
|
||||
|
||||
// CheckUsernamePassword implements the `authenticate` interface of the login
|
||||
func (s *multiStorage) CheckUsernamePassword(ctx context.Context, username, password, id string) error {
|
||||
storage, err := s.storageFromContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return storage.CheckUsernamePassword(username, password, id)
|
||||
}
|
||||
|
||||
// CreateAuthRequest implements the op.Storage interface
|
||||
// it will be called after parsing and validation of the authentication request
|
||||
func (s *multiStorage) CreateAuthRequest(ctx context.Context, authReq *oidc.AuthRequest, userID string) (op.AuthRequest, error) {
|
||||
storage, err := s.storageFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return storage.CreateAuthRequest(ctx, authReq, userID)
|
||||
}
|
||||
|
||||
// AuthRequestByID implements the op.Storage interface
|
||||
// it will be called after the Login UI redirects back to the OIDC endpoint
|
||||
func (s *multiStorage) AuthRequestByID(ctx context.Context, id string) (op.AuthRequest, error) {
|
||||
storage, err := s.storageFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return storage.AuthRequestByID(ctx, id)
|
||||
}
|
||||
|
||||
// AuthRequestByCode implements the op.Storage interface
|
||||
// it will be called after parsing and validation of the token request (in an authorization code flow)
|
||||
func (s *multiStorage) AuthRequestByCode(ctx context.Context, code string) (op.AuthRequest, error) {
|
||||
storage, err := s.storageFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return storage.AuthRequestByCode(ctx, code)
|
||||
}
|
||||
|
||||
// SaveAuthCode implements the op.Storage interface
|
||||
// it will be called after the authentication has been successful and before redirecting the user agent to the redirect_uri
|
||||
// (in an authorization code flow)
|
||||
func (s *multiStorage) SaveAuthCode(ctx context.Context, id string, code string) error {
|
||||
storage, err := s.storageFromContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return storage.SaveAuthCode(ctx, id, code)
|
||||
}
|
||||
|
||||
// DeleteAuthRequest implements the op.Storage interface
|
||||
// it will be called after creating the token response (id and access tokens) for a valid
|
||||
// - authentication request (in an implicit flow)
|
||||
// - token request (in an authorization code flow)
|
||||
func (s *multiStorage) DeleteAuthRequest(ctx context.Context, id string) error {
|
||||
storage, err := s.storageFromContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return storage.DeleteAuthRequest(ctx, id)
|
||||
}
|
||||
|
||||
// CreateAccessToken implements the op.Storage interface
|
||||
// it will be called for all requests able to return an access token (Authorization Code Flow, Implicit Flow, JWT Profile, ...)
|
||||
func (s *multiStorage) CreateAccessToken(ctx context.Context, request op.TokenRequest) (string, time.Time, error) {
|
||||
storage, err := s.storageFromContext(ctx)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
return storage.CreateAccessToken(ctx, request)
|
||||
}
|
||||
|
||||
// CreateAccessAndRefreshTokens implements the op.Storage interface
|
||||
// it will be called for all requests able to return an access and refresh token (Authorization Code Flow, Refresh Token Request)
|
||||
func (s *multiStorage) CreateAccessAndRefreshTokens(ctx context.Context, request op.TokenRequest, currentRefreshToken string) (accessTokenID string, newRefreshToken string, expiration time.Time, err error) {
|
||||
storage, err := s.storageFromContext(ctx)
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
return storage.CreateAccessAndRefreshTokens(ctx, request, currentRefreshToken)
|
||||
}
|
||||
|
||||
// TokenRequestByRefreshToken implements the op.Storage interface
|
||||
// it will be called after parsing and validation of the refresh token request
|
||||
func (s *multiStorage) TokenRequestByRefreshToken(ctx context.Context, refreshToken string) (op.RefreshTokenRequest, error) {
|
||||
storage, err := s.storageFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return storage.TokenRequestByRefreshToken(ctx, refreshToken)
|
||||
}
|
||||
|
||||
// TerminateSession implements the op.Storage interface
|
||||
// it will be called after the user signed out, therefore the access and refresh token of the user of this client must be removed
|
||||
func (s *multiStorage) TerminateSession(ctx context.Context, userID string, clientID string) error {
|
||||
storage, err := s.storageFromContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return storage.TerminateSession(ctx, userID, clientID)
|
||||
}
|
||||
|
||||
// GetRefreshTokenInfo looks up a refresh token and returns the token id and user id.
|
||||
// If given something that is not a refresh token, it must return error.
|
||||
func (s *multiStorage) GetRefreshTokenInfo(ctx context.Context, clientID string, token string) (userID string, tokenID string, err error) {
|
||||
storage, err := s.storageFromContext(ctx)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return storage.GetRefreshTokenInfo(ctx, clientID, token)
|
||||
}
|
||||
|
||||
// RevokeToken implements the op.Storage interface
|
||||
// it will be called after parsing and validation of the token revocation request
|
||||
func (s *multiStorage) RevokeToken(ctx context.Context, token string, userID string, clientID string) *oidc.Error {
|
||||
storage, err := s.storageFromContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return storage.RevokeToken(ctx, token, userID, clientID)
|
||||
}
|
||||
|
||||
// SigningKey implements the op.Storage interface
|
||||
// it will be called when creating the OpenID Provider
|
||||
func (s *multiStorage) SigningKey(ctx context.Context) (op.SigningKey, error) {
|
||||
storage, err := s.storageFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return storage.SigningKey(ctx)
|
||||
}
|
||||
|
||||
// SignatureAlgorithms implements the op.Storage interface
|
||||
// it will be called to get the sign
|
||||
func (s *multiStorage) SignatureAlgorithms(ctx context.Context) ([]jose.SignatureAlgorithm, error) {
|
||||
storage, err := s.storageFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return storage.SignatureAlgorithms(ctx)
|
||||
}
|
||||
|
||||
// KeySet implements the op.Storage interface
|
||||
// it will be called to get the current (public) keys, among others for the keys_endpoint or for validating access_tokens on the userinfo_endpoint, ...
|
||||
func (s *multiStorage) KeySet(ctx context.Context) ([]op.Key, error) {
|
||||
storage, err := s.storageFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return storage.KeySet(ctx)
|
||||
}
|
||||
|
||||
// GetClientByClientID implements the op.Storage interface
|
||||
// it will be called whenever information (type, redirect_uris, ...) about the client behind the client_id is needed
|
||||
func (s *multiStorage) GetClientByClientID(ctx context.Context, clientID string) (op.Client, error) {
|
||||
storage, err := s.storageFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return storage.GetClientByClientID(ctx, clientID)
|
||||
}
|
||||
|
||||
// AuthorizeClientIDSecret implements the op.Storage interface
|
||||
// it will be called for validating the client_id, client_secret on token or introspection requests
|
||||
func (s *multiStorage) AuthorizeClientIDSecret(ctx context.Context, clientID, clientSecret string) error {
|
||||
storage, err := s.storageFromContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return storage.AuthorizeClientIDSecret(ctx, clientID, clientSecret)
|
||||
}
|
||||
|
||||
// SetUserinfoFromScopes implements the op.Storage interface.
|
||||
// Provide an empty implementation and use SetUserinfoFromRequest instead.
|
||||
func (s *multiStorage) SetUserinfoFromScopes(ctx context.Context, userinfo *oidc.UserInfo, userID, clientID string, scopes []string) error {
|
||||
storage, err := s.storageFromContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return storage.SetUserinfoFromScopes(ctx, userinfo, userID, clientID, scopes)
|
||||
}
|
||||
|
||||
// SetUserinfoFromRequests implements the op.CanSetUserinfoFromRequest interface. In the
|
||||
// next major release, it will be required for op.Storage.
|
||||
// It will be called for the creation of an id_token, so we'll just pass it to the private function without any further check
|
||||
func (s *multiStorage) SetUserinfoFromRequest(ctx context.Context, userinfo *oidc.UserInfo, token op.IDTokenRequest, scopes []string) error {
|
||||
storage, err := s.storageFromContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return storage.SetUserinfoFromRequest(ctx, userinfo, token, scopes)
|
||||
}
|
||||
|
||||
// SetUserinfoFromToken implements the op.Storage interface
|
||||
// it will be called for the userinfo endpoint, so we read the token and pass the information from that to the private function
|
||||
func (s *multiStorage) SetUserinfoFromToken(ctx context.Context, userinfo *oidc.UserInfo, tokenID, subject, origin string) error {
|
||||
storage, err := s.storageFromContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return storage.SetUserinfoFromToken(ctx, userinfo, tokenID, subject, origin)
|
||||
}
|
||||
|
||||
// SetIntrospectionFromToken implements the op.Storage interface
|
||||
// it will be called for the introspection endpoint, so we read the token and pass the information from that to the private function
|
||||
func (s *multiStorage) SetIntrospectionFromToken(ctx context.Context, introspection *oidc.IntrospectionResponse, tokenID, subject, clientID string) error {
|
||||
storage, err := s.storageFromContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return storage.SetIntrospectionFromToken(ctx, introspection, tokenID, subject, clientID)
|
||||
}
|
||||
|
||||
// GetPrivateClaimsFromScopes implements the op.Storage interface
|
||||
// it will be called for the creation of a JWT access token to assert claims for custom scopes
|
||||
func (s *multiStorage) GetPrivateClaimsFromScopes(ctx context.Context, userID, clientID string, scopes []string) (claims map[string]any, err error) {
|
||||
storage, err := s.storageFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return storage.GetPrivateClaimsFromScopes(ctx, userID, clientID, scopes)
|
||||
}
|
||||
|
||||
// GetKeyByIDAndClientID implements the op.Storage interface
|
||||
// it will be called to validate the signatures of a JWT (JWT Profile Grant and Authentication)
|
||||
func (s *multiStorage) GetKeyByIDAndClientID(ctx context.Context, keyID, userID string) (*jose.JSONWebKey, error) {
|
||||
storage, err := s.storageFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return storage.GetKeyByIDAndClientID(ctx, keyID, userID)
|
||||
}
|
||||
|
||||
// ValidateJWTProfileScopes implements the op.Storage interface
|
||||
// it will be called to validate the scopes of a JWT Profile Authorization Grant request
|
||||
func (s *multiStorage) ValidateJWTProfileScopes(ctx context.Context, userID string, scopes []string) ([]string, error) {
|
||||
storage, err := s.storageFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return storage.ValidateJWTProfileScopes(ctx, userID, scopes)
|
||||
}
|
||||
|
||||
// Health implements the op.Storage interface
|
||||
func (s *multiStorage) Health(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *multiStorage) storageFromContext(ctx context.Context) (*Storage, *oidc.Error) {
|
||||
storage, ok := s.issuers[op.IssuerFromContext(ctx)]
|
||||
if !ok {
|
||||
return nil, oidc.ErrInvalidRequest().WithDescription("invalid issuer")
|
||||
}
|
||||
return storage, nil
|
||||
}
|
|
@ -22,5 +22,4 @@ type RefreshToken struct {
|
|||
ApplicationID string
|
||||
Expiration time.Time
|
||||
Scopes []string
|
||||
AccessToken string // Token.ID
|
||||
}
|
||||
|
|
|
@ -2,9 +2,6 @@ package storage
|
|||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
@ -20,7 +17,6 @@ type User struct {
|
|||
Phone string
|
||||
PhoneVerified bool
|
||||
PreferredLanguage language.Tag
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
|
@ -37,25 +33,12 @@ type userStore struct {
|
|||
users map[string]*User
|
||||
}
|
||||
|
||||
func StoreFromFile(path string) (UserStore, error) {
|
||||
users := map[string]*User{}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(data, &users); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return userStore{users}, nil
|
||||
}
|
||||
|
||||
func NewUserStore(issuer string) UserStore {
|
||||
hostname := strings.Split(strings.Split(issuer, "://")[1], ":")[0]
|
||||
func NewUserStore() UserStore {
|
||||
return userStore{
|
||||
users: map[string]*User{
|
||||
"id1": {
|
||||
ID: "id1",
|
||||
Username: "test-user@" + hostname,
|
||||
Username: "test-user",
|
||||
Password: "verysecure",
|
||||
FirstName: "Test",
|
||||
LastName: "User",
|
||||
|
@ -64,20 +47,6 @@ func NewUserStore(issuer string) UserStore {
|
|||
Phone: "",
|
||||
PhoneVerified: false,
|
||||
PreferredLanguage: language.German,
|
||||
IsAdmin: true,
|
||||
},
|
||||
"id2": {
|
||||
ID: "id2",
|
||||
Username: "test-user2",
|
||||
Password: "verysecure",
|
||||
FirstName: "Test",
|
||||
LastName: "User2",
|
||||
Email: "test-user2@zitadel.ch",
|
||||
EmailVerified: true,
|
||||
Phone: "",
|
||||
PhoneVerified: false,
|
||||
PreferredLanguage: language.German,
|
||||
IsAdmin: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -1,70 +0,0 @@
|
|||
package storage
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
func TestStoreFromFile(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
pathToFile string
|
||||
content string
|
||||
want UserStore
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "normal user file",
|
||||
pathToFile: "userfile.json",
|
||||
content: `{
|
||||
"id1": {
|
||||
"ID": "id1",
|
||||
"EmailVerified": true,
|
||||
"PreferredLanguage": "DE"
|
||||
}
|
||||
}`,
|
||||
want: userStore{map[string]*User{
|
||||
"id1": {
|
||||
ID: "id1",
|
||||
EmailVerified: true,
|
||||
PreferredLanguage: language.German,
|
||||
},
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "malformed file",
|
||||
pathToFile: "whatever",
|
||||
content: "not a json just a text",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "not existing file",
|
||||
pathToFile: "what/ever/file",
|
||||
wantErr: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
actualPath := path.Join(t.TempDir(), tc.pathToFile)
|
||||
|
||||
if tc.content != "" && tc.pathToFile != "" {
|
||||
if err := os.WriteFile(actualPath, []byte(tc.content), 0666); err != nil {
|
||||
t.Fatalf("cannot create file with test content: %q", tc.content)
|
||||
}
|
||||
}
|
||||
result, err := StoreFromFile(actualPath)
|
||||
if err != nil && !tc.wantErr {
|
||||
t.Errorf("StoreFromFile(%q) returned unexpected error %q", tc.pathToFile, err)
|
||||
} else if err == nil && tc.wantErr {
|
||||
t.Errorf("StoreFromFile(%q) did not return an expected error", tc.pathToFile)
|
||||
}
|
||||
if !tc.wantErr && !reflect.DeepEqual(tc.want, result.(userStore)) {
|
||||
t.Errorf("expected StoreFromFile(%q) = %v, but got %v",
|
||||
tc.pathToFile, tc.want, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
47
go.mod
47
go.mod
|
@ -1,40 +1,35 @@
|
|||
module git.christmann.info/LARA/zitadel-oidc/v3
|
||||
module github.com/zitadel/oidc
|
||||
|
||||
go 1.23.7
|
||||
|
||||
toolchain go1.24.1
|
||||
go 1.18
|
||||
|
||||
require (
|
||||
github.com/bmatcuk/doublestar/v4 v4.8.1
|
||||
github.com/go-chi/chi/v5 v5.2.1
|
||||
github.com/go-jose/go-jose/v4 v4.0.5
|
||||
github.com/golang/mock v1.6.0
|
||||
github.com/google/go-github/v31 v31.0.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/securecookie v1.1.2
|
||||
github.com/jeremija/gosubmit v0.2.8
|
||||
github.com/muhlemmer/gu v0.3.1
|
||||
github.com/muhlemmer/httpforwarded v0.1.0
|
||||
github.com/rs/cors v1.11.1
|
||||
github.com/google/uuid v1.3.1
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/gorilla/schema v1.2.0
|
||||
github.com/gorilla/securecookie v1.1.1
|
||||
github.com/jeremija/gosubmit v0.2.7
|
||||
github.com/rs/cors v1.10.1
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/zitadel/logging v0.6.2
|
||||
github.com/zitadel/schema v1.3.1
|
||||
go.opentelemetry.io/otel v1.29.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
golang.org/x/text v0.26.0
|
||||
github.com/stretchr/testify v1.8.2
|
||||
github.com/zitadel/logging v0.3.4
|
||||
golang.org/x/oauth2 v0.13.0
|
||||
golang.org/x/text v0.13.0
|
||||
gopkg.in/square/go-jose.v2 v2.6.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.29.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.29.0 // indirect
|
||||
golang.org/x/crypto v0.36.0 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/crypto v0.14.0 // indirect
|
||||
golang.org/x/net v0.17.0 // indirect
|
||||
golang.org/x/sys v0.13.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
126
go.sum
126
go.sum
|
@ -1,108 +1,124 @@
|
|||
github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
|
||||
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
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/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-github/v31 v31.0.0 h1:JJUxlP9lFK+ziXKimTCprajMApV1ecWD4NB6CCb0plo=
|
||||
github.com/google/go-github/v31 v31.0.0/go.mod h1:NQPZol8/1sMoWYGN2yaALIBytu17gAWfhbweiEed3pM=
|
||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/jeremija/gosubmit v0.2.8 h1:mmSITBz9JxVtu8eqbN+zmmwX7Ij2RidQxhcwRVI4wqA=
|
||||
github.com/jeremija/gosubmit v0.2.8/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI=
|
||||
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
|
||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc=
|
||||
github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
|
||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/jeremija/gosubmit v0.2.7 h1:At0OhGCFGPXyjPYAsCchoBUhE099pcBXmsb4iZqROIc=
|
||||
github.com/jeremija/gosubmit v0.2.7/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM=
|
||||
github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM=
|
||||
github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY=
|
||||
github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
|
||||
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo=
|
||||
github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/zitadel/logging v0.6.2 h1:MW2kDDR0ieQynPZ0KIZPrh9ote2WkxfBif5QoARDQcU=
|
||||
github.com/zitadel/logging v0.6.2/go.mod h1:z6VWLWUkJpnNVDSLzrPSQSQyttysKZ6bCRongw0ROK4=
|
||||
github.com/zitadel/schema v1.3.1 h1:QT3kwiRIRXXLVAs6gCK/u044WmUVh6IlbLXUsn6yRQU=
|
||||
github.com/zitadel/schema v1.3.1/go.mod h1:071u7D2LQacy1HAN+YnMd/mx1qVE2isb0Mjeqg46xnU=
|
||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
||||
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
|
||||
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
|
||||
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
||||
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zitadel/logging v0.3.4 h1:9hZsTjMMTE3X2LUi0xcF9Q9EdLo+FAezeu52ireBbHM=
|
||||
github.com/zitadel/logging v0.3.4/go.mod h1:aPpLQhE+v6ocNK0TWrBrd363hZ95KcI17Q1ixAQwZF0=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY=
|
||||
golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220207234003-57398862261d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
|
||||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U=
|
||||
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
|
||||
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
@ -1,58 +0,0 @@
|
|||
// Package gen allows generating of example tokens and claims.
|
||||
//
|
||||
// go run ./internal/testutil/gen
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
tu "git.christmann.info/LARA/zitadel-oidc/v3/internal/testutil"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
)
|
||||
|
||||
var custom = map[string]any{
|
||||
"foo": "Hello, World!",
|
||||
"bar": struct {
|
||||
Count int `json:"count,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}{
|
||||
Count: 22,
|
||||
Tags: []string{"some", "tags"},
|
||||
},
|
||||
}
|
||||
|
||||
func main() {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
|
||||
accessToken, atClaims := tu.NewAccessTokenCustom(
|
||||
tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience,
|
||||
tu.ValidExpiration.AddDate(99, 0, 0), tu.ValidJWTID,
|
||||
tu.ValidClientID, tu.ValidSkew, custom,
|
||||
)
|
||||
atHash, err := oidc.ClaimHash(accessToken, tu.SignatureAlgorithm)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
idToken, idClaims := tu.NewIDTokenCustom(
|
||||
tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience,
|
||||
tu.ValidExpiration.AddDate(99, 0, 0), tu.ValidAuthTime,
|
||||
tu.ValidNonce, tu.ValidACR, tu.ValidAMR, tu.ValidClientID,
|
||||
tu.ValidSkew, atHash, custom,
|
||||
)
|
||||
|
||||
fmt.Println("access token claims:")
|
||||
if err := enc.Encode(atClaims); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("access token:\n%s\n", accessToken)
|
||||
|
||||
fmt.Println("ID token claims:")
|
||||
if err := enc.Encode(idClaims); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("ID token:\n%s\n", idToken)
|
||||
}
|
|
@ -1,180 +0,0 @@
|
|||
// Package testuril helps setting up required data for testing,
|
||||
// such as tokens, claims and verifiers.
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
jose "github.com/go-jose/go-jose/v4"
|
||||
"github.com/muhlemmer/gu"
|
||||
)
|
||||
|
||||
// KeySet implements oidc.Keys
|
||||
type KeySet struct{}
|
||||
|
||||
// VerifySignature implments op.KeySet.
|
||||
func (KeySet) VerifySignature(ctx context.Context, jws *jose.JSONWebSignature) (payload []byte, err error) {
|
||||
if err = ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return jws.Verify(WebKey.Public())
|
||||
}
|
||||
|
||||
// use a reproducible signing key
|
||||
const webkeyJSON = `{"kty":"RSA","kid":"1","alg":"PS512","n":"x6JoG8t2Li68JSwPwnh51TvHYFf3z72tQ3wmJG3VosU6MdJF0gSTCIwflOJ38OWE6hYtN1WAeyBy2CYdnXd1QZzkK_apGK4M7hsNA9jCTg8NOZjLPL0ww1jp7313Skla7mbm90uNdg4TUNp2n_r-sCYywI-9cfSlhzLSksxKK_BRdzy6xW20daAcI-mErQXIcvdYIguunJk_uTb8kJedsWMcQ4Mb57QujUok2Z2YabWyb9Fi1_StixXJvd_WEu93SHNMORB0u6ymnO3aZJdATLdhtcP-qsVicQhffpqVazmZQPf7K-7n4I5vJE4g9XXzZ2dSKSp3Ewe_nna_2kvbCw","e":"AQAB","d":"sl3F_QeF2O-CxQegMRYpbL6Tfd47GM6VDxXOkn_cACmNvFPudB4ILPvdf830cjTv06Lq1WS8fcZZNgygK0A_cNc3-pvRK67e-KMMtuIlgU7rdwmwlN1Iw1Ee-w6z1ZjC-PzR4iQMCW28DmKS2I-OnV4TvH7xOe7nMmvTPrvujV__YKfUxvAWXJG7_wtaJBGplezn5nNsKG2Ot9h0mhMdYUgGC36wLxo3Q5d4m79EXQYdhm89EfxogwvMmHRes5PNpHRuDZRHGAI4RZi2KvgmqF07e1Qdq4TqbQnY5pCYrdjqvEFFjGC6jTE-ak_b21FcSVy-9aZHyf04U4g5-cIUEQ","p":"7AaicFryJCHRekdSkx8tfPxaSiyEuN8jhP9cLqs4rLkIbrSHmanPhjnLe-Tlh3icQ8hPoy6WC8ktLwsrzbfGIh4U_zgAfvtD1Y_lZM-YSWZsxqlrGiI5do11iVzzoy4a1XdkgOjHQz9y6J-uoA9jY8ILG7VaEZQnaYwWZV3cspk","q":"2Ide9hlwthXJQJYqI0mibM5BiGBxJ4CafPmF1DYNXggBCczZ6ERGReNTGM_AEhy5mvLXUH6uBSOJlfHTYzx49C1GgIO3hEWVEGAKAytVRL6RfAkVSOXMQUp-HjXKpGg_Nx1SJxQf3rulbW8HXO4KqIlloyIXpPQSK7jB8A4hJUM","dp":"1nmc6F4sRNsaQHRJO_mL21RxM4_KtzfFThjCCoJ6iLHHUNnpkp_1PTKNjrLMRFM8JHgErfMqU-FmlqYfEtvZRq1xRQ39nWX0GT-eIwJljuVtGQVglqnc77bRxJXbqz-9EJdik6VzVM92Op7IDxiMp1zvvSkJhInNWqL6wvgNEZk","dq":"dlHizlAwiw90ndpwxD-khhhfLwqkSpW31br0KnYu78cn6hcKrCVC0UXbTp-XsU4JDmbMyauvpBc7Q7iVbpDI94UWFXvkeF8diYkxb3HqclpAXasI-oC4EKWILTHvvc9JW_Clx7zzfV7Ekvws5dcd8-LAq1gh232TwFiBgY_3BMk","qi":"E1k_9W3odXgcmIP2PCJztE7hB7jeuAL1ElAY88VJBBPY670uwOEjKL2VfQuz9q9IjzLAvcgf7vS9blw2RHP_XqHqSOlJWGwvMQTF0Q8zLknCgKt8q7HQQNWIJcBZ8qdUVn02-qf4E3tgZ3JHaHNs8imA_L-__WoUmzC4z5jH_lM"}`
|
||||
|
||||
const SignatureAlgorithm = jose.RS256
|
||||
|
||||
var (
|
||||
WebKey jose.JSONWebKey
|
||||
Signer jose.Signer
|
||||
)
|
||||
|
||||
func init() {
|
||||
err := json.Unmarshal([]byte(webkeyJSON), &WebKey)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
Signer, err = jose.NewSigner(jose.SigningKey{Algorithm: SignatureAlgorithm, Key: WebKey}, nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
type JWTProfileKeyStorage struct{}
|
||||
|
||||
func (JWTProfileKeyStorage) GetKeyByIDAndClientID(ctx context.Context, keyID string, clientID string) (*jose.JSONWebKey, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return gu.Ptr(WebKey.Public()), nil
|
||||
}
|
||||
|
||||
func signEncodeTokenClaims(claims any) string {
|
||||
payload, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
object, err := Signer.Sign(payload)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
token, err := object.CompactSerialize()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func claimsMap(claims any) map[string]any {
|
||||
data, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
dst := make(map[string]any)
|
||||
if err = json.Unmarshal(data, &dst); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func NewIDTokenCustom(issuer, subject string, audience []string, expiration, authTime time.Time, nonce string, acr string, amr []string, clientID string, skew time.Duration, atHash string, custom map[string]any) (string, *oidc.IDTokenClaims) {
|
||||
claims := oidc.NewIDTokenClaims(issuer, subject, audience, expiration, authTime, nonce, acr, amr, clientID, skew)
|
||||
claims.AccessTokenHash = atHash
|
||||
claims.Claims = custom
|
||||
token := signEncodeTokenClaims(claims)
|
||||
|
||||
// set this so that assertion in tests will work
|
||||
claims.SignatureAlg = SignatureAlgorithm
|
||||
claims.Claims = claimsMap(claims)
|
||||
return token, claims
|
||||
}
|
||||
|
||||
// NewIDToken creates a new IDTokenClaims with passed data and returns a signed token and claims.
|
||||
func NewIDToken(issuer, subject string, audience []string, expiration, authTime time.Time, nonce string, acr string, amr []string, clientID string, skew time.Duration, atHash string) (string, *oidc.IDTokenClaims) {
|
||||
return NewIDTokenCustom(issuer, subject, audience, expiration, authTime, nonce, acr, amr, clientID, skew, atHash, nil)
|
||||
}
|
||||
|
||||
func NewAccessTokenCustom(issuer, subject string, audience []string, expiration time.Time, jwtid, clientID string, skew time.Duration, custom map[string]any) (string, *oidc.AccessTokenClaims) {
|
||||
claims := oidc.NewAccessTokenClaims(issuer, subject, audience, expiration, jwtid, clientID, skew)
|
||||
claims.Claims = custom
|
||||
token := signEncodeTokenClaims(claims)
|
||||
|
||||
// set this so that assertion in tests will work
|
||||
claims.SignatureAlg = SignatureAlgorithm
|
||||
claims.Claims = claimsMap(claims)
|
||||
return token, claims
|
||||
}
|
||||
|
||||
// NewAcccessToken creates a new AccessTokenClaims with passed data and returns a signed token and claims.
|
||||
func NewAccessToken(issuer, subject string, audience []string, expiration time.Time, jwtid, clientID string, skew time.Duration) (string, *oidc.AccessTokenClaims) {
|
||||
return NewAccessTokenCustom(issuer, subject, audience, expiration, jwtid, clientID, skew, nil)
|
||||
}
|
||||
|
||||
func NewJWTProfileAssertion(issuer, clientID string, audience []string, issuedAt, expiration time.Time) (string, *oidc.JWTTokenRequest) {
|
||||
req := &oidc.JWTTokenRequest{
|
||||
Issuer: issuer,
|
||||
Subject: clientID,
|
||||
Audience: audience,
|
||||
ExpiresAt: oidc.FromTime(expiration),
|
||||
IssuedAt: oidc.FromTime(issuedAt),
|
||||
}
|
||||
// make sure the private claim map is set correctly
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err = json.Unmarshal(data, req); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return signEncodeTokenClaims(req), req
|
||||
}
|
||||
|
||||
const InvalidSignatureToken = `eyJhbGciOiJQUzUxMiJ9.eyJpc3MiOiJsb2NhbC5jb20iLCJzdWIiOiJ0aW1AbG9jYWwuY29tIiwiYXVkIjpbInVuaXQiLCJ0ZXN0IiwiNTU1NjY2Il0sImV4cCI6MTY3Nzg0MDQzMSwiaWF0IjoxNjc3ODQwMzcwLCJhdXRoX3RpbWUiOjE2Nzc4NDAzMTAsIm5vbmNlIjoiMTIzNDUiLCJhY3IiOiJzb21ldGhpbmciLCJhbXIiOlsiZm9vIiwiYmFyIl0sImF6cCI6IjU1NTY2NiJ9.DtZmvVkuE4Hw48ijBMhRJbxEWCr_WEYuPQBMY73J9TP6MmfeNFkjVJf4nh4omjB9gVLnQ-xhEkNOe62FS5P0BB2VOxPuHZUj34dNspCgG3h98fGxyiMb5vlIYAHDF9T-w_LntlYItohv63MmdYR-hPpAqjXE7KOfErf-wUDGE9R3bfiQ4HpTdyFJB1nsToYrZ9lhP2mzjTCTs58ckZfQ28DFHn_lfHWpR4rJBgvLx7IH4rMrUayr09Ap-PxQLbv0lYMtmgG1z3JK8MXnuYR0UJdZnEIezOzUTlThhCXB-nvuAXYjYxZZTR0FtlgZUHhIpYK0V2abf_Q_Or36akNCUg`
|
||||
|
||||
// These variables always result in a valid token
|
||||
var (
|
||||
ValidIssuer = "local.com"
|
||||
ValidSubject = "tim@local.com"
|
||||
ValidAudience = []string{"unit", "test"}
|
||||
ValidAuthTime = time.Now().Add(-time.Minute) // authtime is always 1 minute in the past
|
||||
ValidExpiration = ValidAuthTime.Add(2 * time.Minute) // token is always 1 more minute available
|
||||
ValidJWTID = "9876"
|
||||
ValidNonce = "12345"
|
||||
ValidACR = "something"
|
||||
ValidAMR = []string{"foo", "bar"}
|
||||
ValidClientID = "555666"
|
||||
ValidSkew = time.Second
|
||||
)
|
||||
|
||||
// ValidIDToken returns a token and claims that are in the token.
|
||||
// It uses the Valid* global variables and the token will always
|
||||
// pass verification.
|
||||
func ValidIDToken() (string, *oidc.IDTokenClaims) {
|
||||
return NewIDToken(ValidIssuer, ValidSubject, ValidAudience, ValidExpiration, ValidAuthTime, ValidNonce, ValidACR, ValidAMR, ValidClientID, ValidSkew, "")
|
||||
}
|
||||
|
||||
// ValidAccessToken returns a token and claims that are in the token.
|
||||
// It uses the Valid* global variables and the token always passes
|
||||
// verification within the same test run.
|
||||
func ValidAccessToken() (string, *oidc.AccessTokenClaims) {
|
||||
return NewAccessToken(ValidIssuer, ValidSubject, ValidAudience, ValidExpiration, ValidJWTID, ValidClientID, ValidSkew)
|
||||
}
|
||||
|
||||
func ValidJWTProfileAssertion() (string, *oidc.JWTTokenRequest) {
|
||||
return NewJWTProfileAssertion(ValidClientID, ValidClientID, []string{ValidIssuer}, time.Now(), ValidExpiration)
|
||||
}
|
||||
|
||||
// ACRVerify is a oidc.ACRVerifier func.
|
||||
func ACRVerify(acr string) error {
|
||||
if acr != ValidACR {
|
||||
return errors.New("invalid acr")
|
||||
}
|
||||
return nil
|
||||
}
|
|
@ -1,53 +1,48 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"github.com/zitadel/logging"
|
||||
"go.opentelemetry.io/otel"
|
||||
"github.com/gorilla/schema"
|
||||
"golang.org/x/oauth2"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/crypto"
|
||||
httphelper "git.christmann.info/LARA/zitadel-oidc/v3/pkg/http"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
"github.com/zitadel/oidc/pkg/crypto"
|
||||
httphelper "github.com/zitadel/oidc/pkg/http"
|
||||
"github.com/zitadel/oidc/pkg/oidc"
|
||||
)
|
||||
|
||||
var (
|
||||
Encoder = httphelper.Encoder(oidc.NewEncoder())
|
||||
Tracer = otel.Tracer("github.com/zitadel/oidc/pkg/client")
|
||||
)
|
||||
var Encoder = func() httphelper.Encoder {
|
||||
e := schema.NewEncoder()
|
||||
e.RegisterEncoder(oidc.SpaceDelimitedArray{}, func(value reflect.Value) string {
|
||||
return value.Interface().(oidc.SpaceDelimitedArray).Encode()
|
||||
})
|
||||
return e
|
||||
}()
|
||||
|
||||
// Discover calls the discovery endpoint of the provided issuer and returns its configuration
|
||||
// It accepts an optional argument "wellknownUrl" which can be used to overide the dicovery endpoint url
|
||||
func Discover(ctx context.Context, issuer string, httpClient *http.Client, wellKnownUrl ...string) (*oidc.DiscoveryConfiguration, error) {
|
||||
ctx, span := Tracer.Start(ctx, "Discover")
|
||||
defer span.End()
|
||||
|
||||
func Discover(issuer string, httpClient *http.Client, wellKnownUrl ...string) (*oidc.DiscoveryConfiguration, error) {
|
||||
wellKnown := strings.TrimSuffix(issuer, "/") + oidc.DiscoveryEndpoint
|
||||
if len(wellKnownUrl) == 1 && wellKnownUrl[0] != "" {
|
||||
wellKnown = wellKnownUrl[0]
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnown, nil)
|
||||
req, err := http.NewRequest("GET", wellKnown, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
discoveryConfig := new(oidc.DiscoveryConfiguration)
|
||||
err = httphelper.HttpRequest(httpClient, req, &discoveryConfig)
|
||||
if err != nil {
|
||||
return nil, errors.Join(oidc.ErrDiscoveryFailed, err)
|
||||
return nil, err
|
||||
}
|
||||
if logger, ok := logging.FromContext(ctx); ok {
|
||||
logger.Debug("discover", "config", discoveryConfig)
|
||||
}
|
||||
|
||||
if discoveryConfig.Issuer != issuer {
|
||||
return nil, oidc.ErrIssuerInvalid
|
||||
}
|
||||
|
@ -59,15 +54,12 @@ type TokenEndpointCaller interface {
|
|||
HttpClient() *http.Client
|
||||
}
|
||||
|
||||
func CallTokenEndpoint(ctx context.Context, request any, caller TokenEndpointCaller) (newToken *oauth2.Token, err error) {
|
||||
return callTokenEndpoint(ctx, request, nil, caller)
|
||||
func CallTokenEndpoint(request interface{}, caller TokenEndpointCaller) (newToken *oauth2.Token, err error) {
|
||||
return callTokenEndpoint(request, nil, caller)
|
||||
}
|
||||
|
||||
func callTokenEndpoint(ctx context.Context, request any, authFn any, caller TokenEndpointCaller) (newToken *oauth2.Token, err error) {
|
||||
ctx, span := Tracer.Start(ctx, "callTokenEndpoint")
|
||||
defer span.End()
|
||||
|
||||
req, err := httphelper.FormRequest(ctx, caller.TokenEndpoint(), request, Encoder, authFn)
|
||||
func callTokenEndpoint(request interface{}, authFn interface{}, caller TokenEndpointCaller) (newToken *oauth2.Token, err error) {
|
||||
req, err := httphelper.FormRequest(caller.TokenEndpoint(), request, Encoder, authFn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -75,18 +67,12 @@ func callTokenEndpoint(ctx context.Context, request any, authFn any, caller Toke
|
|||
if err := httphelper.HttpRequest(caller.HttpClient(), req, &tokenRes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token := &oauth2.Token{
|
||||
return &oauth2.Token{
|
||||
AccessToken: tokenRes.AccessToken,
|
||||
TokenType: tokenRes.TokenType,
|
||||
RefreshToken: tokenRes.RefreshToken,
|
||||
Expiry: time.Now().UTC().Add(time.Duration(tokenRes.ExpiresIn) * time.Second),
|
||||
}
|
||||
if tokenRes.IDToken != "" {
|
||||
token = token.WithExtra(map[string]any{
|
||||
"id_token": tokenRes.IDToken,
|
||||
})
|
||||
}
|
||||
return token, nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
type EndSessionCaller interface {
|
||||
|
@ -94,16 +80,8 @@ type EndSessionCaller interface {
|
|||
HttpClient() *http.Client
|
||||
}
|
||||
|
||||
func CallEndSessionEndpoint(ctx context.Context, request any, authFn any, caller EndSessionCaller) (*url.URL, error) {
|
||||
ctx, span := Tracer.Start(ctx, "CallEndSessionEndpoint")
|
||||
defer span.End()
|
||||
|
||||
endpoint := caller.GetEndSessionEndpoint()
|
||||
if endpoint == "" {
|
||||
return nil, fmt.Errorf("end session %w", ErrEndpointNotSet)
|
||||
}
|
||||
|
||||
req, err := httphelper.FormRequest(ctx, endpoint, request, Encoder, authFn)
|
||||
func CallEndSessionEndpoint(request interface{}, authFn interface{}, caller EndSessionCaller) (*url.URL, error) {
|
||||
req, err := httphelper.FormRequest(caller.GetEndSessionEndpoint(), request, Encoder, authFn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -112,9 +90,6 @@ func CallEndSessionEndpoint(ctx context.Context, request any, authFn any, caller
|
|||
return http.ErrUseLastResponse
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
|
@ -145,16 +120,8 @@ type RevokeRequest struct {
|
|||
ClientSecret string `schema:"client_secret"`
|
||||
}
|
||||
|
||||
func CallRevokeEndpoint(ctx context.Context, request any, authFn any, caller RevokeCaller) error {
|
||||
ctx, span := Tracer.Start(ctx, "CallRevokeEndpoint")
|
||||
defer span.End()
|
||||
|
||||
endpoint := caller.GetRevokeEndpoint()
|
||||
if endpoint == "" {
|
||||
return fmt.Errorf("revoke %w", ErrEndpointNotSet)
|
||||
}
|
||||
|
||||
req, err := httphelper.FormRequest(ctx, endpoint, request, Encoder, authFn)
|
||||
func CallRevokeEndpoint(request interface{}, authFn interface{}, caller RevokeCaller) error {
|
||||
req, err := httphelper.FormRequest(caller.GetRevokeEndpoint(), request, Encoder, authFn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -181,28 +148,13 @@ func CallRevokeEndpoint(ctx context.Context, request any, authFn any, caller Rev
|
|||
return nil
|
||||
}
|
||||
|
||||
func CallTokenExchangeEndpoint(ctx context.Context, request any, authFn any, caller TokenEndpointCaller) (resp *oidc.TokenExchangeResponse, err error) {
|
||||
ctx, span := Tracer.Start(ctx, "CallTokenExchangeEndpoint")
|
||||
defer span.End()
|
||||
|
||||
req, err := httphelper.FormRequest(ctx, caller.TokenEndpoint(), request, Encoder, authFn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokenRes := new(oidc.TokenExchangeResponse)
|
||||
if err := httphelper.HttpRequest(caller.HttpClient(), req, &tokenRes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tokenRes, nil
|
||||
}
|
||||
|
||||
func NewSignerFromPrivateKeyByte(key []byte, keyID string) (jose.Signer, error) {
|
||||
privateKey, algorithm, err := crypto.BytesToPrivateKey(key)
|
||||
privateKey, err := crypto.BytesToPrivateKey(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
signingKey := jose.SigningKey{
|
||||
Algorithm: algorithm,
|
||||
Algorithm: jose.RS256,
|
||||
Key: &jose.JSONWebKey{Key: privateKey, KeyID: keyID},
|
||||
}
|
||||
return jose.NewSigner(signingKey, &jose.SignerOptions{})
|
||||
|
@ -215,98 +167,7 @@ func SignedJWTProfileAssertion(clientID string, audience []string, expiration ti
|
|||
Issuer: clientID,
|
||||
Subject: clientID,
|
||||
Audience: audience,
|
||||
ExpiresAt: oidc.FromTime(exp),
|
||||
IssuedAt: oidc.FromTime(iat),
|
||||
ExpiresAt: oidc.Time(exp),
|
||||
IssuedAt: oidc.Time(iat),
|
||||
}, signer)
|
||||
}
|
||||
|
||||
type DeviceAuthorizationCaller interface {
|
||||
GetDeviceAuthorizationEndpoint() string
|
||||
HttpClient() *http.Client
|
||||
}
|
||||
|
||||
func CallDeviceAuthorizationEndpoint(ctx context.Context, request *oidc.ClientCredentialsRequest, caller DeviceAuthorizationCaller, authFn any) (*oidc.DeviceAuthorizationResponse, error) {
|
||||
ctx, span := Tracer.Start(ctx, "CallDeviceAuthorizationEndpoint")
|
||||
defer span.End()
|
||||
|
||||
endpoint := caller.GetDeviceAuthorizationEndpoint()
|
||||
if endpoint == "" {
|
||||
return nil, fmt.Errorf("device authorization %w", ErrEndpointNotSet)
|
||||
}
|
||||
|
||||
req, err := httphelper.FormRequest(ctx, endpoint, request, Encoder, authFn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if request.ClientSecret != "" {
|
||||
req.SetBasicAuth(request.ClientID, request.ClientSecret)
|
||||
}
|
||||
|
||||
resp := new(oidc.DeviceAuthorizationResponse)
|
||||
if err := httphelper.HttpRequest(caller.HttpClient(), req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
type DeviceAccessTokenRequest struct {
|
||||
*oidc.ClientCredentialsRequest
|
||||
oidc.DeviceAccessTokenRequest
|
||||
}
|
||||
|
||||
func CallDeviceAccessTokenEndpoint(ctx context.Context, request *DeviceAccessTokenRequest, caller TokenEndpointCaller) (*oidc.AccessTokenResponse, error) {
|
||||
ctx, span := Tracer.Start(ctx, "CallDeviceAccessTokenEndpoint")
|
||||
defer span.End()
|
||||
|
||||
req, err := httphelper.FormRequest(ctx, caller.TokenEndpoint(), request, Encoder, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if request.ClientSecret != "" {
|
||||
req.SetBasicAuth(request.ClientID, request.ClientSecret)
|
||||
}
|
||||
|
||||
resp := new(oidc.AccessTokenResponse)
|
||||
if err := httphelper.HttpRequest(caller.HttpClient(), req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func PollDeviceAccessTokenEndpoint(ctx context.Context, interval time.Duration, request *DeviceAccessTokenRequest, caller TokenEndpointCaller) (*oidc.AccessTokenResponse, error) {
|
||||
ctx, span := Tracer.Start(ctx, "PollDeviceAccessTokenEndpoint")
|
||||
defer span.End()
|
||||
|
||||
for {
|
||||
timer := time.After(interval)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-timer:
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, interval)
|
||||
defer cancel()
|
||||
|
||||
resp, err := CallDeviceAccessTokenEndpoint(ctx, request, caller)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
interval += 5 * time.Second
|
||||
}
|
||||
var target *oidc.Error
|
||||
if !errors.As(err, &target) {
|
||||
return nil, err
|
||||
}
|
||||
switch target.ErrorType {
|
||||
case oidc.AuthorizationPending:
|
||||
continue
|
||||
case oidc.SlowDown:
|
||||
interval += 5 * time.Second
|
||||
continue
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,59 +0,0 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDiscover(t *testing.T) {
|
||||
type wantFields struct {
|
||||
UILocalesSupported bool
|
||||
}
|
||||
|
||||
type args struct {
|
||||
issuer string
|
||||
wellKnownUrl []string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantFields *wantFields
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "spotify", // https://github.com/zitadel/oidc/issues/406
|
||||
args: args{
|
||||
issuer: "https://accounts.spotify.com",
|
||||
},
|
||||
wantFields: &wantFields{
|
||||
UILocalesSupported: true,
|
||||
},
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "discovery failed",
|
||||
args: args{
|
||||
issuer: "https://example.com",
|
||||
},
|
||||
wantErr: oidc.ErrDiscoveryFailed,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := Discover(context.Background(), tt.args.issuer, http.DefaultClient, tt.args.wellKnownUrl...)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
if tt.wantFields == nil {
|
||||
return
|
||||
}
|
||||
assert.Equal(t, tt.args.issuer, got.Issuer)
|
||||
if tt.wantFields.UILocalesSupported {
|
||||
assert.NotEmpty(t, got.UILocalesSupported)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
package client
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrEndpointNotSet = errors.New("endpoint not set")
|
|
@ -1,594 +0,0 @@
|
|||
package client_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jeremija/gosubmit"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/example/server/exampleop"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/example/server/storage"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/client/rp"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/client/rs"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/client/tokenexchange"
|
||||
httphelper "git.christmann.info/LARA/zitadel-oidc/v3/pkg/http"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/op"
|
||||
)
|
||||
|
||||
var Logger = slog.New(
|
||||
slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
AddSource: true,
|
||||
Level: slog.LevelDebug,
|
||||
}),
|
||||
)
|
||||
|
||||
var CTX context.Context
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(func() int {
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT)
|
||||
defer cancel()
|
||||
CTX, cancel = context.WithTimeout(ctx, time.Minute)
|
||||
defer cancel()
|
||||
return m.Run()
|
||||
}())
|
||||
}
|
||||
|
||||
func TestRelyingPartySession(t *testing.T) {
|
||||
for _, wrapServer := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint("wrapServer ", wrapServer), func(t *testing.T) {
|
||||
testRelyingPartySession(t, wrapServer)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testRelyingPartySession(t *testing.T, wrapServer bool) {
|
||||
t.Log("------- start example OP ------")
|
||||
targetURL := "http://local-site"
|
||||
exampleStorage := storage.NewStorage(storage.NewUserStore(targetURL))
|
||||
var dh deferredHandler
|
||||
opServer := httptest.NewServer(&dh)
|
||||
defer opServer.Close()
|
||||
t.Logf("auth server at %s", opServer.URL)
|
||||
dh.Handler = exampleop.SetupServer(opServer.URL, exampleStorage, Logger, wrapServer)
|
||||
|
||||
seed := rand.New(rand.NewSource(int64(os.Getpid()) + time.Now().UnixNano()))
|
||||
clientID := t.Name() + "-" + strconv.FormatInt(seed.Int63(), 25)
|
||||
|
||||
t.Log("------- run authorization code flow ------")
|
||||
provider, tokens := RunAuthorizationCodeFlow(t, opServer, clientID, "secret")
|
||||
|
||||
t.Log("------- refresh tokens ------")
|
||||
|
||||
newTokens, err := rp.RefreshTokens[*oidc.IDTokenClaims](CTX, provider, tokens.RefreshToken, "", "")
|
||||
require.NoError(t, err, "refresh token")
|
||||
assert.NotNil(t, newTokens, "access token")
|
||||
t.Logf("new access token %s", newTokens.AccessToken)
|
||||
t.Logf("new refresh token %s", newTokens.RefreshToken)
|
||||
t.Logf("new token type %s", newTokens.TokenType)
|
||||
t.Logf("new expiry %s", newTokens.Expiry.Format(time.RFC3339))
|
||||
require.NotEmpty(t, newTokens.AccessToken, "new accessToken")
|
||||
assert.NotEmpty(t, newTokens.IDToken, "new idToken")
|
||||
assert.NotNil(t, newTokens.IDTokenClaims)
|
||||
assert.Equal(t, newTokens.IDTokenClaims.Subject, tokens.IDTokenClaims.Subject)
|
||||
|
||||
t.Log("------ end session (logout) ------")
|
||||
|
||||
newLoc, err := rp.EndSession(CTX, provider, tokens.IDToken, "", "")
|
||||
require.NoError(t, err, "logout")
|
||||
if newLoc != nil {
|
||||
t.Logf("redirect to %s", newLoc)
|
||||
} else {
|
||||
t.Logf("no redirect")
|
||||
}
|
||||
|
||||
t.Log("------ attempt refresh again (should fail) ------")
|
||||
t.Log("trying original refresh token", tokens.RefreshToken)
|
||||
_, err = rp.RefreshTokens[*oidc.IDTokenClaims](CTX, provider, tokens.RefreshToken, "", "")
|
||||
assert.Errorf(t, err, "refresh with original")
|
||||
if newTokens.RefreshToken != "" {
|
||||
t.Log("trying replacement refresh token", newTokens.RefreshToken)
|
||||
_, err = rp.RefreshTokens[*oidc.IDTokenClaims](CTX, provider, newTokens.RefreshToken, "", "")
|
||||
assert.Errorf(t, err, "refresh with replacement")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelyingPartyWithSigningAlgsFromDiscovery(t *testing.T) {
|
||||
targetURL := "http://local-site"
|
||||
localURL, err := url.Parse(targetURL + "/login?requestID=1234")
|
||||
require.NoError(t, err, "local url")
|
||||
|
||||
t.Log("------- start example OP ------")
|
||||
seed := rand.New(rand.NewSource(int64(os.Getpid()) + time.Now().UnixNano()))
|
||||
clientID := t.Name() + "-" + strconv.FormatInt(seed.Int63(), 25)
|
||||
clientSecret := "secret"
|
||||
client := storage.WebClient(clientID, clientSecret, targetURL)
|
||||
storage.RegisterClients(client)
|
||||
exampleStorage := storage.NewStorage(storage.NewUserStore(targetURL))
|
||||
var dh deferredHandler
|
||||
opServer := httptest.NewServer(&dh)
|
||||
defer opServer.Close()
|
||||
dh.Handler = exampleop.SetupServer(opServer.URL, exampleStorage, Logger, true)
|
||||
|
||||
t.Log("------- create RP ------")
|
||||
provider, err := rp.NewRelyingPartyOIDC(
|
||||
CTX,
|
||||
opServer.URL,
|
||||
clientID,
|
||||
clientSecret,
|
||||
targetURL,
|
||||
[]string{"openid"},
|
||||
rp.WithSigningAlgsFromDiscovery(),
|
||||
)
|
||||
require.NoError(t, err, "new rp")
|
||||
|
||||
t.Log("------- run authorization code flow ------")
|
||||
jar, err := cookiejar.New(nil)
|
||||
require.NoError(t, err, "create cookie jar")
|
||||
httpClient := &http.Client{
|
||||
Timeout: time.Second * 5,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Jar: jar,
|
||||
}
|
||||
state := "state-" + strconv.FormatInt(seed.Int63(), 25)
|
||||
capturedW := httptest.NewRecorder()
|
||||
get := httptest.NewRequest("GET", localURL.String(), nil)
|
||||
rp.AuthURLHandler(func() string { return state }, provider,
|
||||
rp.WithPromptURLParam("Hello, World!", "Goodbye, World!"),
|
||||
rp.WithURLParam("custom", "param"),
|
||||
)(capturedW, get)
|
||||
defer func() {
|
||||
if t.Failed() {
|
||||
t.Log("response body (redirect from RP to OP)", capturedW.Body.String())
|
||||
}
|
||||
}()
|
||||
resp := capturedW.Result()
|
||||
startAuthURL, err := resp.Location()
|
||||
require.NoError(t, err, "get redirect")
|
||||
loginPageURL := getRedirect(t, "get redirect to login page", httpClient, startAuthURL)
|
||||
form := getForm(t, "get login form", httpClient, loginPageURL)
|
||||
defer func() {
|
||||
if t.Failed() {
|
||||
t.Logf("login form (unfilled): %s", string(form))
|
||||
}
|
||||
}()
|
||||
postLoginRedirectURL := fillForm(t, "fill login form", httpClient, form, loginPageURL,
|
||||
gosubmit.Set("username", "test-user@local-site"),
|
||||
gosubmit.Set("password", "verysecure"),
|
||||
)
|
||||
codeBearingURL := getRedirect(t, "get redirect with code", httpClient, postLoginRedirectURL)
|
||||
capturedW = httptest.NewRecorder()
|
||||
get = httptest.NewRequest("GET", codeBearingURL.String(), nil)
|
||||
var idToken string
|
||||
redirect := func(w http.ResponseWriter, r *http.Request, newTokens *oidc.Tokens[*oidc.IDTokenClaims], state string, rp rp.RelyingParty, info *oidc.UserInfo) {
|
||||
idToken = newTokens.IDToken
|
||||
http.Redirect(w, r, targetURL, http.StatusFound)
|
||||
}
|
||||
rp.CodeExchangeHandler(rp.UserinfoCallback(redirect), provider)(capturedW, get)
|
||||
defer func() {
|
||||
if t.Failed() {
|
||||
t.Log("token exchange response body", capturedW.Body.String())
|
||||
require.GreaterOrEqual(t, capturedW.Code, 200, "captured response code")
|
||||
}
|
||||
}()
|
||||
|
||||
t.Log("------- verify id token ------")
|
||||
_, err = rp.VerifyIDToken[*oidc.IDTokenClaims](CTX, idToken, provider.IDTokenVerifier())
|
||||
require.NoError(t, err, "verify id token")
|
||||
}
|
||||
|
||||
func TestResourceServerTokenExchange(t *testing.T) {
|
||||
for _, wrapServer := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint("wrapServer ", wrapServer), func(t *testing.T) {
|
||||
testResourceServerTokenExchange(t, wrapServer)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testResourceServerTokenExchange(t *testing.T, wrapServer bool) {
|
||||
t.Log("------- start example OP ------")
|
||||
targetURL := "http://local-site"
|
||||
exampleStorage := storage.NewStorage(storage.NewUserStore(targetURL))
|
||||
var dh deferredHandler
|
||||
opServer := httptest.NewServer(&dh)
|
||||
defer opServer.Close()
|
||||
t.Logf("auth server at %s", opServer.URL)
|
||||
dh.Handler = exampleop.SetupServer(opServer.URL, exampleStorage, Logger, wrapServer)
|
||||
|
||||
seed := rand.New(rand.NewSource(int64(os.Getpid()) + time.Now().UnixNano()))
|
||||
clientID := t.Name() + "-" + strconv.FormatInt(seed.Int63(), 25)
|
||||
clientSecret := "secret"
|
||||
|
||||
t.Log("------- run authorization code flow ------")
|
||||
provider, tokens := RunAuthorizationCodeFlow(t, opServer, clientID, clientSecret)
|
||||
|
||||
resourceServer, err := rs.NewResourceServerClientCredentials(CTX, opServer.URL, clientID, clientSecret)
|
||||
require.NoError(t, err, "new resource server")
|
||||
|
||||
t.Log("------- exchage refresh tokens (impersonation) ------")
|
||||
|
||||
tokenExchangeResponse, err := tokenexchange.ExchangeToken(
|
||||
CTX,
|
||||
resourceServer,
|
||||
tokens.RefreshToken,
|
||||
oidc.RefreshTokenType,
|
||||
"",
|
||||
"",
|
||||
[]string{},
|
||||
[]string{},
|
||||
[]string{"profile", "custom_scope:impersonate:id2"},
|
||||
oidc.RefreshTokenType,
|
||||
)
|
||||
require.NoError(t, err, "refresh token")
|
||||
require.NotNil(t, tokenExchangeResponse, "token exchange response")
|
||||
assert.Equal(t, tokenExchangeResponse.IssuedTokenType, oidc.RefreshTokenType)
|
||||
assert.NotEmpty(t, tokenExchangeResponse.AccessToken, "access token")
|
||||
assert.NotEmpty(t, tokenExchangeResponse.RefreshToken, "refresh token")
|
||||
assert.Equal(t, []string(tokenExchangeResponse.Scopes), []string{"profile", "custom_scope:impersonate:id2"})
|
||||
|
||||
t.Log("------ end session (logout) ------")
|
||||
|
||||
newLoc, err := rp.EndSession(CTX, provider, tokens.IDToken, "", "")
|
||||
require.NoError(t, err, "logout")
|
||||
if newLoc != nil {
|
||||
t.Logf("redirect to %s", newLoc)
|
||||
} else {
|
||||
t.Logf("no redirect")
|
||||
}
|
||||
|
||||
t.Log("------- attempt exchage again (should fail) ------")
|
||||
|
||||
tokenExchangeResponse, err = tokenexchange.ExchangeToken(
|
||||
CTX,
|
||||
resourceServer,
|
||||
tokens.RefreshToken,
|
||||
oidc.RefreshTokenType,
|
||||
"",
|
||||
"",
|
||||
[]string{},
|
||||
[]string{},
|
||||
[]string{"profile", "custom_scope:impersonate:id2"},
|
||||
oidc.RefreshTokenType,
|
||||
)
|
||||
require.Error(t, err, "refresh token")
|
||||
assert.Contains(t, err.Error(), "subject_token is invalid")
|
||||
require.Nil(t, tokenExchangeResponse, "token exchange response")
|
||||
}
|
||||
|
||||
func RunAuthorizationCodeFlow(t *testing.T, opServer *httptest.Server, clientID, clientSecret string) (provider rp.RelyingParty, tokens *oidc.Tokens[*oidc.IDTokenClaims]) {
|
||||
targetURL := "http://local-site"
|
||||
localURL, err := url.Parse(targetURL + "/login?requestID=1234")
|
||||
require.NoError(t, err, "local url")
|
||||
|
||||
client := storage.WebClient(clientID, clientSecret, targetURL)
|
||||
storage.RegisterClients(client)
|
||||
|
||||
jar, err := cookiejar.New(nil)
|
||||
require.NoError(t, err, "create cookie jar")
|
||||
httpClient := &http.Client{
|
||||
Timeout: time.Second * 5,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Jar: jar,
|
||||
}
|
||||
|
||||
t.Log("------- create RP ------")
|
||||
key := []byte("test1234test1234")
|
||||
cookieHandler := httphelper.NewCookieHandler(key, key, httphelper.WithUnsecure())
|
||||
provider, err = rp.NewRelyingPartyOIDC(
|
||||
CTX,
|
||||
opServer.URL,
|
||||
clientID,
|
||||
clientSecret,
|
||||
targetURL,
|
||||
[]string{"openid", "email", "profile", "offline_access"},
|
||||
rp.WithPKCE(cookieHandler),
|
||||
rp.WithAuthStyle(oauth2.AuthStyleInHeader),
|
||||
rp.WithVerifierOpts(
|
||||
rp.WithIssuedAtOffset(5*time.Second),
|
||||
rp.WithSupportedSigningAlgorithms("RS256", "RS384", "RS512", "ES256", "ES384", "ES512"),
|
||||
),
|
||||
)
|
||||
require.NoError(t, err, "new rp")
|
||||
|
||||
t.Log("------- get redirect from local client (rp) to OP ------")
|
||||
seed := rand.New(rand.NewSource(int64(os.Getpid()) + time.Now().UnixNano()))
|
||||
state := "state-" + strconv.FormatInt(seed.Int63(), 25)
|
||||
capturedW := httptest.NewRecorder()
|
||||
get := httptest.NewRequest("GET", localURL.String(), nil)
|
||||
rp.AuthURLHandler(func() string { return state }, provider,
|
||||
rp.WithPromptURLParam("Hello, World!", "Goodbye, World!"),
|
||||
rp.WithURLParam("custom", "param"),
|
||||
)(capturedW, get)
|
||||
|
||||
defer func() {
|
||||
if t.Failed() {
|
||||
t.Log("response body (redirect from RP to OP)", capturedW.Body.String())
|
||||
}
|
||||
}()
|
||||
require.GreaterOrEqual(t, capturedW.Code, 200, "captured response code")
|
||||
require.Less(t, capturedW.Code, 400, "captured response code")
|
||||
require.Contains(t, capturedW.Body.String(), `prompt=Hello%2C+World%21+Goodbye%2C+World%21`)
|
||||
require.Contains(t, capturedW.Body.String(), `custom=param`)
|
||||
|
||||
//nolint:bodyclose
|
||||
resp := capturedW.Result()
|
||||
jar.SetCookies(localURL, resp.Cookies())
|
||||
|
||||
startAuthURL, err := resp.Location()
|
||||
require.NoError(t, err, "get redirect")
|
||||
assert.NotEmpty(t, startAuthURL, "login url")
|
||||
t.Log("Starting auth at", startAuthURL)
|
||||
|
||||
t.Log("------- get redirect to OP to login page ------")
|
||||
loginPageURL := getRedirect(t, "get redirect to login page", httpClient, startAuthURL)
|
||||
t.Log("login page URL", loginPageURL)
|
||||
|
||||
t.Log("------- get login form ------")
|
||||
form := getForm(t, "get login form", httpClient, loginPageURL)
|
||||
t.Log("login form (unfilled)", string(form))
|
||||
defer func() {
|
||||
if t.Failed() {
|
||||
t.Logf("login form (unfilled): %s", string(form))
|
||||
}
|
||||
}()
|
||||
|
||||
t.Log("------- post to login form, get redirect to OP ------")
|
||||
postLoginRedirectURL := fillForm(t, "fill login form", httpClient, form, loginPageURL,
|
||||
gosubmit.Set("username", "test-user@local-site"),
|
||||
gosubmit.Set("password", "verysecure"))
|
||||
t.Logf("Get redirect from %s", postLoginRedirectURL)
|
||||
|
||||
t.Log("------- redirect from OP back to RP ------")
|
||||
codeBearingURL := getRedirect(t, "get redirect with code", httpClient, postLoginRedirectURL)
|
||||
t.Logf("Redirect with code %s", codeBearingURL)
|
||||
|
||||
t.Log("------- exchange code for tokens ------")
|
||||
capturedW = httptest.NewRecorder()
|
||||
get = httptest.NewRequest("GET", codeBearingURL.String(), nil)
|
||||
for _, cookie := range jar.Cookies(codeBearingURL) {
|
||||
get.Header["Cookie"] = append(get.Header["Cookie"], cookie.String())
|
||||
t.Logf("setting cookie %s", cookie)
|
||||
}
|
||||
|
||||
var email string
|
||||
redirect := func(w http.ResponseWriter, r *http.Request, newTokens *oidc.Tokens[*oidc.IDTokenClaims], state string, rp rp.RelyingParty, info *oidc.UserInfo) {
|
||||
tokens = newTokens
|
||||
require.NotNil(t, tokens, "tokens")
|
||||
require.NotNil(t, info, "info")
|
||||
t.Log("access token", tokens.AccessToken)
|
||||
t.Log("refresh token", tokens.RefreshToken)
|
||||
t.Log("id token", tokens.IDToken)
|
||||
t.Log("email", info.Email)
|
||||
|
||||
email = info.Email
|
||||
http.Redirect(w, r, targetURL, 302)
|
||||
}
|
||||
rp.CodeExchangeHandler(rp.UserinfoCallback(redirect), provider, rp.WithURLParam("custom", "param"))(capturedW, get)
|
||||
|
||||
defer func() {
|
||||
if t.Failed() {
|
||||
t.Log("token exchange response body", capturedW.Body.String())
|
||||
require.GreaterOrEqual(t, capturedW.Code, 200, "captured response code")
|
||||
}
|
||||
}()
|
||||
require.Less(t, capturedW.Code, 400, "token exchange response code")
|
||||
// TODO: how to check the custom header was sent to the server?
|
||||
|
||||
//nolint:bodyclose
|
||||
resp = capturedW.Result()
|
||||
|
||||
authorizedURL, err := resp.Location()
|
||||
require.NoError(t, err, "get fully-authorizied redirect location")
|
||||
require.Equal(t, targetURL, authorizedURL.String(), "fully-authorizied redirect location")
|
||||
|
||||
require.NotEmpty(t, tokens.IDToken, "id token")
|
||||
assert.NotEmpty(t, tokens.RefreshToken, "refresh token")
|
||||
assert.NotEmpty(t, tokens.AccessToken, "access token")
|
||||
assert.NotEmpty(t, email, "email")
|
||||
|
||||
return provider, tokens
|
||||
}
|
||||
|
||||
func TestClientCredentials(t *testing.T) {
|
||||
targetURL := "http://local-site"
|
||||
exampleStorage := storage.NewStorage(storage.NewUserStore(targetURL))
|
||||
var dh deferredHandler
|
||||
opServer := httptest.NewServer(&dh)
|
||||
defer opServer.Close()
|
||||
t.Logf("auth server at %s", opServer.URL)
|
||||
dh.Handler = exampleop.SetupServer(opServer.URL, exampleStorage, Logger, true)
|
||||
|
||||
provider, err := rp.NewRelyingPartyOIDC(
|
||||
CTX,
|
||||
opServer.URL,
|
||||
"sid1",
|
||||
"verysecret",
|
||||
targetURL,
|
||||
[]string{"openid"},
|
||||
)
|
||||
require.NoError(t, err, "new rp")
|
||||
|
||||
token, err := rp.ClientCredentials(CTX, provider, nil)
|
||||
require.NoError(t, err, "ClientCredentials call")
|
||||
require.NotNil(t, token)
|
||||
assert.NotEmpty(t, token.AccessToken)
|
||||
}
|
||||
|
||||
func TestErrorFromPromptNone(t *testing.T) {
|
||||
jar, err := cookiejar.New(nil)
|
||||
require.NoError(t, err, "create cookie jar")
|
||||
httpClient := &http.Client{
|
||||
Timeout: time.Second * 5,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Jar: jar,
|
||||
}
|
||||
|
||||
t.Log("------- start example OP ------")
|
||||
targetURL := "http://local-site"
|
||||
exampleStorage := storage.NewStorage(storage.NewUserStore(targetURL))
|
||||
var dh deferredHandler
|
||||
opServer := httptest.NewServer(&dh)
|
||||
defer opServer.Close()
|
||||
t.Logf("auth server at %s", opServer.URL)
|
||||
dh.Handler = exampleop.SetupServer(opServer.URL, exampleStorage, Logger, false, op.WithHttpInterceptors(
|
||||
func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Logf("request to %s", r.URL)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
},
|
||||
))
|
||||
seed := rand.New(rand.NewSource(int64(os.Getpid()) + time.Now().UnixNano()))
|
||||
clientID := t.Name() + "-" + strconv.FormatInt(seed.Int63(), 25)
|
||||
clientSecret := "secret"
|
||||
client := storage.WebClient(clientID, clientSecret, targetURL)
|
||||
storage.RegisterClients(client)
|
||||
|
||||
t.Log("------- create RP ------")
|
||||
key := []byte("test1234test1234")
|
||||
cookieHandler := httphelper.NewCookieHandler(key, key, httphelper.WithUnsecure())
|
||||
provider, err := rp.NewRelyingPartyOIDC(
|
||||
CTX,
|
||||
opServer.URL,
|
||||
clientID,
|
||||
clientSecret,
|
||||
targetURL,
|
||||
[]string{"openid", "email", "profile", "offline_access"},
|
||||
rp.WithPKCE(cookieHandler),
|
||||
rp.WithVerifierOpts(
|
||||
rp.WithIssuedAtOffset(5*time.Second),
|
||||
rp.WithSupportedSigningAlgorithms("RS256", "RS384", "RS512", "ES256", "ES384", "ES512"),
|
||||
),
|
||||
)
|
||||
require.NoError(t, err, "new rp")
|
||||
|
||||
t.Log("------- start auth flow with prompt=none ------- ")
|
||||
state := "state-32892"
|
||||
capturedW := httptest.NewRecorder()
|
||||
localURL, err := url.Parse(targetURL + "/login")
|
||||
require.NoError(t, err)
|
||||
|
||||
get := httptest.NewRequest("GET", localURL.String(), nil)
|
||||
rp.AuthURLHandler(func() string { return state }, provider,
|
||||
rp.WithPromptURLParam("none"),
|
||||
rp.WithResponseModeURLParam(oidc.ResponseModeFragment),
|
||||
)(capturedW, get)
|
||||
|
||||
defer func() {
|
||||
if t.Failed() {
|
||||
t.Log("response body (redirect from RP to OP)", capturedW.Body.String())
|
||||
}
|
||||
}()
|
||||
require.GreaterOrEqual(t, capturedW.Code, 200, "captured response code")
|
||||
require.Less(t, capturedW.Code, 400, "captured response code")
|
||||
|
||||
//nolint:bodyclose
|
||||
resp := capturedW.Result()
|
||||
jar.SetCookies(localURL, resp.Cookies())
|
||||
|
||||
startAuthURL, err := resp.Location()
|
||||
require.NoError(t, err, "get redirect")
|
||||
assert.NotEmpty(t, startAuthURL, "login url")
|
||||
t.Log("Starting auth at", startAuthURL)
|
||||
|
||||
t.Log("------- get redirect from OP ------")
|
||||
loginPageURL := getRedirect(t, "get redirect to login page", httpClient, startAuthURL)
|
||||
t.Log("login page URL", loginPageURL)
|
||||
|
||||
require.Contains(t, loginPageURL.String(), `error=login_required`, "prompt=none should error")
|
||||
require.Contains(t, loginPageURL.String(), `local-site#error=`, "response_mode=fragment means '#' instead of '?'")
|
||||
}
|
||||
|
||||
type deferredHandler struct {
|
||||
http.Handler
|
||||
}
|
||||
|
||||
func getRedirect(t *testing.T, desc string, httpClient *http.Client, uri *url.URL) *url.URL {
|
||||
req := &http.Request{
|
||||
Method: "GET",
|
||||
URL: uri,
|
||||
Header: make(http.Header),
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
require.NoError(t, err, "GET "+uri.String())
|
||||
|
||||
defer func() {
|
||||
if t.Failed() {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Logf("%s: GET %s: body: %s", desc, uri, string(body))
|
||||
}
|
||||
}()
|
||||
|
||||
//nolint:errcheck
|
||||
defer resp.Body.Close()
|
||||
redirect, err := resp.Location()
|
||||
require.NoErrorf(t, err, "%s: get redirect %s", desc, uri)
|
||||
require.NotEmptyf(t, redirect, "%s: get redirect %s", desc, uri)
|
||||
return redirect
|
||||
}
|
||||
|
||||
func getForm(t *testing.T, desc string, httpClient *http.Client, uri *url.URL) []byte {
|
||||
req := &http.Request{
|
||||
Method: "GET",
|
||||
URL: uri,
|
||||
Header: make(http.Header),
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
require.NoErrorf(t, err, "%s: GET %s", desc, uri)
|
||||
//nolint:errcheck
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err, "%s: read GET %s", desc, uri)
|
||||
return body
|
||||
}
|
||||
|
||||
func fillForm(t *testing.T, desc string, httpClient *http.Client, body []byte, uri *url.URL, opts ...gosubmit.Option) *url.URL {
|
||||
// TODO: switch to io.NopCloser when go1.15 support is dropped
|
||||
req := gosubmit.ParseWithURL(io.NopCloser(bytes.NewReader(body)), uri.String()).FirstForm().Testing(t).NewTestRequest(
|
||||
append([]gosubmit.Option{gosubmit.AutoFill()}, opts...)...,
|
||||
)
|
||||
if req.URL.Scheme == "" {
|
||||
req.URL = uri
|
||||
t.Log("request lost it's proto..., adding back... request now", req.URL)
|
||||
}
|
||||
req.RequestURI = "" // bug in gosubmit?
|
||||
resp, err := httpClient.Do(req)
|
||||
require.NoErrorf(t, err, "%s: POST %s", desc, uri)
|
||||
|
||||
//nolint:errcheck
|
||||
defer resp.Body.Close()
|
||||
defer func() {
|
||||
if t.Failed() {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Logf("%s: GET %s: body: %s", desc, uri, string(body))
|
||||
}
|
||||
}()
|
||||
|
||||
redirect, err := resp.Location()
|
||||
require.NoErrorf(t, err, "%s: redirect for POST %s", desc, uri)
|
||||
return redirect
|
||||
}
|
|
@ -1,18 +1,17 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/http"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
"github.com/zitadel/oidc/pkg/http"
|
||||
"github.com/zitadel/oidc/pkg/oidc"
|
||||
)
|
||||
|
||||
// JWTProfileExchange handles the oauth2 jwt profile exchange
|
||||
func JWTProfileExchange(ctx context.Context, jwtProfileGrantRequest *oidc.JWTProfileGrantRequest, caller TokenEndpointCaller) (*oauth2.Token, error) {
|
||||
return CallTokenEndpoint(ctx, jwtProfileGrantRequest, caller)
|
||||
func JWTProfileExchange(jwtProfileGrantRequest *oidc.JWTProfileGrantRequest, caller TokenEndpointCaller) (*oauth2.Token, error) {
|
||||
return CallTokenEndpoint(jwtProfileGrantRequest, caller)
|
||||
}
|
||||
|
||||
func ClientAssertionCodeOptions(assertion string) []oauth2.AuthCodeOption {
|
||||
|
|
|
@ -2,7 +2,7 @@ package client
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"io/ioutil"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -10,7 +10,7 @@ const (
|
|||
applicationKey = "application"
|
||||
)
|
||||
|
||||
type KeyFile struct {
|
||||
type keyFile struct {
|
||||
Type string `json:"type"` // serviceaccount or application
|
||||
KeyID string `json:"keyId"`
|
||||
Key string `json:"key"`
|
||||
|
@ -23,16 +23,16 @@ type KeyFile struct {
|
|||
ClientID string `json:"clientId"`
|
||||
}
|
||||
|
||||
func ConfigFromKeyFile(path string) (*KeyFile, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
func ConfigFromKeyFile(path string) (*keyFile, error) {
|
||||
data, err := ioutil.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ConfigFromKeyFileData(data)
|
||||
}
|
||||
|
||||
func ConfigFromKeyFileData(data []byte) (*KeyFile, error) {
|
||||
var f KeyFile
|
||||
func ConfigFromKeyFileData(data []byte) (*keyFile, error) {
|
||||
var f keyFile
|
||||
if err := json.Unmarshal(data, &f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
@ -1,25 +1,19 @@
|
|||
package profile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
jose "github.com/go-jose/go-jose/v4"
|
||||
"golang.org/x/oauth2"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/client"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
"github.com/zitadel/oidc/pkg/client"
|
||||
"github.com/zitadel/oidc/pkg/oidc"
|
||||
)
|
||||
|
||||
type TokenSource interface {
|
||||
oauth2.TokenSource
|
||||
TokenCtx(context.Context) (*oauth2.Token, error)
|
||||
}
|
||||
|
||||
// jwtProfileTokenSource implement the oauth2.TokenSource
|
||||
// it will request a token using the OAuth2 JWT Profile Grant
|
||||
// therefore sending an `assertion` by signing a JWT with the provided private key
|
||||
// therefore sending an `assertion` by singing a JWT with the provided private key
|
||||
type jwtProfileTokenSource struct {
|
||||
clientID string
|
||||
audience []string
|
||||
|
@ -29,38 +23,23 @@ type jwtProfileTokenSource struct {
|
|||
tokenEndpoint string
|
||||
}
|
||||
|
||||
// NewJWTProfileTokenSourceFromKeyFile returns an implementation of TokenSource
|
||||
// It will request a token using the OAuth2 JWT Profile Grant,
|
||||
// therefore sending an `assertion` by singing a JWT with the provided private key from jsonFile.
|
||||
//
|
||||
// The passed context is only used for the call to the Discover endpoint.
|
||||
func NewJWTProfileTokenSourceFromKeyFile(ctx context.Context, issuer, jsonFile string, scopes []string, options ...func(source *jwtProfileTokenSource)) (TokenSource, error) {
|
||||
keyData, err := client.ConfigFromKeyFile(jsonFile)
|
||||
func NewJWTProfileTokenSourceFromKeyFile(issuer, keyPath string, scopes []string, options ...func(source *jwtProfileTokenSource)) (oauth2.TokenSource, error) {
|
||||
keyData, err := client.ConfigFromKeyFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewJWTProfileTokenSource(ctx, issuer, keyData.UserID, keyData.KeyID, []byte(keyData.Key), scopes, options...)
|
||||
return NewJWTProfileTokenSource(issuer, keyData.UserID, keyData.KeyID, []byte(keyData.Key), scopes, options...)
|
||||
}
|
||||
|
||||
// NewJWTProfileTokenSourceFromKeyFileData returns an implementation of oauth2.TokenSource
|
||||
// It will request a token using the OAuth2 JWT Profile Grant,
|
||||
// therefore sending an `assertion` by singing a JWT with the provided private key in jsonData.
|
||||
//
|
||||
// The passed context is only used for the call to the Discover endpoint.
|
||||
func NewJWTProfileTokenSourceFromKeyFileData(ctx context.Context, issuer string, jsonData []byte, scopes []string, options ...func(source *jwtProfileTokenSource)) (TokenSource, error) {
|
||||
keyData, err := client.ConfigFromKeyFileData(jsonData)
|
||||
func NewJWTProfileTokenSourceFromKeyFileData(issuer string, data []byte, scopes []string, options ...func(source *jwtProfileTokenSource)) (oauth2.TokenSource, error) {
|
||||
keyData, err := client.ConfigFromKeyFileData(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewJWTProfileTokenSource(ctx, issuer, keyData.UserID, keyData.KeyID, []byte(keyData.Key), scopes, options...)
|
||||
return NewJWTProfileTokenSource(issuer, keyData.UserID, keyData.KeyID, []byte(keyData.Key), scopes, options...)
|
||||
}
|
||||
|
||||
// NewJWTProfileSource returns an implementation of oauth2.TokenSource
|
||||
// It will request a token using the OAuth2 JWT Profile Grant,
|
||||
// therefore sending an `assertion` by singing a JWT with the provided private key.
|
||||
//
|
||||
// The passed context is only used for the call to the Discover endpoint.
|
||||
func NewJWTProfileTokenSource(ctx context.Context, issuer, clientID, keyID string, key []byte, scopes []string, options ...func(source *jwtProfileTokenSource)) (TokenSource, error) {
|
||||
func NewJWTProfileTokenSource(issuer, clientID, keyID string, key []byte, scopes []string, options ...func(source *jwtProfileTokenSource)) (oauth2.TokenSource, error) {
|
||||
signer, err := client.NewSignerFromPrivateKeyByte(key, keyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
@ -76,7 +55,7 @@ func NewJWTProfileTokenSource(ctx context.Context, issuer, clientID, keyID strin
|
|||
opt(source)
|
||||
}
|
||||
if source.tokenEndpoint == "" {
|
||||
config, err := client.Discover(ctx, issuer, source.httpClient)
|
||||
config, err := client.Discover(issuer, source.httpClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -85,13 +64,13 @@ func NewJWTProfileTokenSource(ctx context.Context, issuer, clientID, keyID strin
|
|||
return source, nil
|
||||
}
|
||||
|
||||
func WithHTTPClient(client *http.Client) func(source *jwtProfileTokenSource) {
|
||||
func WithHTTPClient(client *http.Client) func(*jwtProfileTokenSource) {
|
||||
return func(source *jwtProfileTokenSource) {
|
||||
source.httpClient = client
|
||||
}
|
||||
}
|
||||
|
||||
func WithStaticTokenEndpoint(issuer, tokenEndpoint string) func(source *jwtProfileTokenSource) {
|
||||
func WithStaticTokenEndpoint(issuer, tokenEndpoint string) func(*jwtProfileTokenSource) {
|
||||
return func(source *jwtProfileTokenSource) {
|
||||
source.tokenEndpoint = tokenEndpoint
|
||||
}
|
||||
|
@ -106,13 +85,9 @@ func (j *jwtProfileTokenSource) HttpClient() *http.Client {
|
|||
}
|
||||
|
||||
func (j *jwtProfileTokenSource) Token() (*oauth2.Token, error) {
|
||||
return j.TokenCtx(context.Background())
|
||||
}
|
||||
|
||||
func (j *jwtProfileTokenSource) TokenCtx(ctx context.Context) (*oauth2.Token, error) {
|
||||
assertion, err := client.SignedJWTProfileAssertion(j.clientID, j.audience, time.Hour, j.signer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.JWTProfileExchange(ctx, oidc.NewJWTProfileGrantRequest(assertion, j.scopes...), j)
|
||||
return client.JWTProfileExchange(oidc.NewJWTProfileGrantRequest(assertion, j.scopes...), j)
|
||||
}
|
||||
|
|
|
@ -4,22 +4,22 @@ import (
|
|||
"context"
|
||||
"net/http"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/client/rp"
|
||||
httphelper "git.christmann.info/LARA/zitadel-oidc/v3/pkg/http"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
"github.com/zitadel/oidc/pkg/client/rp"
|
||||
httphelper "github.com/zitadel/oidc/pkg/http"
|
||||
"github.com/zitadel/oidc/pkg/oidc"
|
||||
)
|
||||
|
||||
const (
|
||||
loginPath = "/login"
|
||||
)
|
||||
|
||||
func CodeFlow[C oidc.IDClaims](ctx context.Context, relyingParty rp.RelyingParty, callbackPath, port string, stateProvider func() string) *oidc.Tokens[C] {
|
||||
func CodeFlow(ctx context.Context, relyingParty rp.RelyingParty, callbackPath, port string, stateProvider func() string) *oidc.Tokens {
|
||||
codeflowCtx, codeflowCancel := context.WithCancel(ctx)
|
||||
defer codeflowCancel()
|
||||
|
||||
tokenChan := make(chan *oidc.Tokens[C], 1)
|
||||
tokenChan := make(chan *oidc.Tokens, 1)
|
||||
|
||||
callback := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[C], state string, rp rp.RelyingParty) {
|
||||
callback := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens, state string, rp rp.RelyingParty) {
|
||||
tokenChan <- tokens
|
||||
msg := "<p><strong>Success!</strong></p>"
|
||||
msg = msg + "<p>You are authenticated and can now return to the CLI.</p>"
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package rp
|
||||
|
||||
import (
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc/grants/tokenexchange"
|
||||
"github.com/zitadel/oidc/pkg/oidc/grants/tokenexchange"
|
||||
)
|
||||
|
||||
// DelegationTokenRequest is an implementation of TokenExchangeRequest
|
||||
|
|
|
@ -1,69 +0,0 @@
|
|||
package rp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/client"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
)
|
||||
|
||||
func newDeviceClientCredentialsRequest(scopes []string, rp RelyingParty) (*oidc.ClientCredentialsRequest, error) {
|
||||
confg := rp.OAuthConfig()
|
||||
req := &oidc.ClientCredentialsRequest{
|
||||
Scope: scopes,
|
||||
ClientID: confg.ClientID,
|
||||
ClientSecret: confg.ClientSecret,
|
||||
}
|
||||
|
||||
if signer := rp.Signer(); signer != nil {
|
||||
assertion, err := client.SignedJWTProfileAssertion(rp.OAuthConfig().ClientID, []string{rp.Issuer()}, time.Hour, signer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build assertion: %w", err)
|
||||
}
|
||||
req.ClientAssertion = assertion
|
||||
req.ClientAssertionType = oidc.ClientAssertionTypeJWTAssertion
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// DeviceAuthorization starts a new Device Authorization flow as defined
|
||||
// in RFC 8628, section 3.1 and 3.2:
|
||||
// https://www.rfc-editor.org/rfc/rfc8628#section-3.1
|
||||
func DeviceAuthorization(ctx context.Context, scopes []string, rp RelyingParty, authFn any) (*oidc.DeviceAuthorizationResponse, error) {
|
||||
ctx, span := client.Tracer.Start(ctx, "DeviceAuthorization")
|
||||
defer span.End()
|
||||
|
||||
ctx = logCtxWithRPData(ctx, rp, "function", "DeviceAuthorization")
|
||||
req, err := newDeviceClientCredentialsRequest(scopes, rp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client.CallDeviceAuthorizationEndpoint(ctx, req, rp, authFn)
|
||||
}
|
||||
|
||||
// DeviceAccessToken attempts to obtain tokens from a Device Authorization,
|
||||
// by means of polling as defined in RFC, section 3.3 and 3.4:
|
||||
// https://www.rfc-editor.org/rfc/rfc8628#section-3.4
|
||||
func DeviceAccessToken(ctx context.Context, deviceCode string, interval time.Duration, rp RelyingParty) (resp *oidc.AccessTokenResponse, err error) {
|
||||
ctx, span := client.Tracer.Start(ctx, "DeviceAccessToken")
|
||||
defer span.End()
|
||||
|
||||
ctx = logCtxWithRPData(ctx, rp, "function", "DeviceAccessToken")
|
||||
req := &client.DeviceAccessTokenRequest{
|
||||
DeviceAccessTokenRequest: oidc.DeviceAccessTokenRequest{
|
||||
GrantType: oidc.GrantTypeDeviceCode,
|
||||
DeviceCode: deviceCode,
|
||||
},
|
||||
}
|
||||
|
||||
req.ClientCredentialsRequest, err = newDeviceClientCredentialsRequest(nil, rp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client.PollDeviceAccessTokenEndpoint(ctx, interval, req, tokenEndpointCaller{rp})
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
package rp
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrRelyingPartyNotSupportRevokeCaller = errors.New("RelyingParty does not support RevokeCaller")
|
279
pkg/client/rp/integration_test.go
Normal file
279
pkg/client/rp/integration_test.go
Normal file
|
@ -0,0 +1,279 @@
|
|||
package rp_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/oidc/example/server/exampleop"
|
||||
"github.com/zitadel/oidc/example/server/storage"
|
||||
|
||||
"github.com/jeremija/gosubmit"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zitadel/oidc/pkg/client/rp"
|
||||
httphelper "github.com/zitadel/oidc/pkg/http"
|
||||
"github.com/zitadel/oidc/pkg/oidc"
|
||||
)
|
||||
|
||||
func TestRelyingPartySession(t *testing.T) {
|
||||
t.Log("------- start example OP ------")
|
||||
ctx := context.Background()
|
||||
exampleStorage := storage.NewStorage(storage.NewUserStore())
|
||||
var dh deferredHandler
|
||||
opServer := httptest.NewServer(&dh)
|
||||
defer opServer.Close()
|
||||
t.Logf("auth server at %s", opServer.URL)
|
||||
dh.Handler = exampleop.SetupServer(ctx, opServer.URL, exampleStorage)
|
||||
|
||||
targetURL := "http://local-site"
|
||||
localURL, err := url.Parse(targetURL + "/login?requestID=1234")
|
||||
require.NoError(t, err, "local url")
|
||||
|
||||
seed := rand.New(rand.NewSource(int64(os.Getpid()) + time.Now().UnixNano()))
|
||||
clientID := t.Name() + "-" + strconv.FormatInt(seed.Int63(), 25)
|
||||
client := storage.WebClient(clientID, "secret", targetURL)
|
||||
storage.RegisterClients(client)
|
||||
|
||||
jar, err := cookiejar.New(nil)
|
||||
require.NoError(t, err, "create cookie jar")
|
||||
httpClient := &http.Client{
|
||||
Timeout: time.Second * 5,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Jar: jar,
|
||||
}
|
||||
|
||||
t.Log("------- create RP ------")
|
||||
key := []byte("test1234test1234")
|
||||
cookieHandler := httphelper.NewCookieHandler(key, key, httphelper.WithUnsecure())
|
||||
provider, err := rp.NewRelyingPartyOIDC(
|
||||
opServer.URL,
|
||||
clientID,
|
||||
"secret",
|
||||
targetURL,
|
||||
[]string{"openid", "email", "profile", "offline_access"},
|
||||
rp.WithPKCE(cookieHandler),
|
||||
rp.WithVerifierOpts(
|
||||
rp.WithIssuedAtOffset(5*time.Second),
|
||||
rp.WithSupportedSigningAlgorithms("RS256", "RS384", "RS512", "ES256", "ES384", "ES512"),
|
||||
),
|
||||
)
|
||||
|
||||
t.Log("------- get redirect from local client (rp) to OP ------")
|
||||
state := "state-" + strconv.FormatInt(seed.Int63(), 25)
|
||||
capturedW := httptest.NewRecorder()
|
||||
get := httptest.NewRequest("GET", localURL.String(), nil)
|
||||
rp.AuthURLHandler(func() string { return state }, provider,
|
||||
rp.WithPromptURLParam("Hello, World!", "Goodbye, World!"),
|
||||
rp.WithURLParam("custom", "param"),
|
||||
)(capturedW, get)
|
||||
|
||||
defer func() {
|
||||
if t.Failed() {
|
||||
t.Log("response body (redirect from RP to OP)", capturedW.Body.String())
|
||||
}
|
||||
}()
|
||||
require.GreaterOrEqual(t, capturedW.Code, 200, "captured response code")
|
||||
require.Less(t, capturedW.Code, 400, "captured response code")
|
||||
require.Contains(t, capturedW.Body.String(), `prompt=Hello%2C+World%21+Goodbye%2C+World%21`)
|
||||
require.Contains(t, capturedW.Body.String(), `custom=param`)
|
||||
|
||||
//nolint:bodyclose
|
||||
resp := capturedW.Result()
|
||||
jar.SetCookies(localURL, resp.Cookies())
|
||||
|
||||
startAuthURL, err := resp.Location()
|
||||
require.NoError(t, err, "get redirect")
|
||||
assert.NotEmpty(t, startAuthURL, "login url")
|
||||
t.Log("Starting auth at", startAuthURL)
|
||||
|
||||
t.Log("------- get redirect to OP to login page ------")
|
||||
loginPageURL := getRedirect(t, "get redirect to login page", httpClient, startAuthURL)
|
||||
t.Log("login page URL", loginPageURL)
|
||||
|
||||
t.Log("------- get login form ------")
|
||||
form := getForm(t, "get login form", httpClient, loginPageURL)
|
||||
t.Log("login form (unfilled)", string(form))
|
||||
defer func() {
|
||||
if t.Failed() {
|
||||
t.Logf("login form (unfilled): %s", string(form))
|
||||
}
|
||||
}()
|
||||
|
||||
t.Log("------- post to login form, get redirect to OP ------")
|
||||
postLoginRedirectURL := fillForm(t, "fill login form", httpClient, form, loginPageURL,
|
||||
gosubmit.Set("username", "test-user"),
|
||||
gosubmit.Set("password", "verysecure"))
|
||||
t.Logf("Get redirect from %s", postLoginRedirectURL)
|
||||
|
||||
t.Log("------- redirect from OP back to RP ------")
|
||||
codeBearingURL := getRedirect(t, "get redirect with code", httpClient, postLoginRedirectURL)
|
||||
t.Logf("Redirect with code %s", codeBearingURL)
|
||||
|
||||
t.Log("------- exchange code for tokens ------")
|
||||
capturedW = httptest.NewRecorder()
|
||||
get = httptest.NewRequest("GET", codeBearingURL.String(), nil)
|
||||
for _, cookie := range jar.Cookies(codeBearingURL) {
|
||||
get.Header["Cookie"] = append(get.Header["Cookie"], cookie.String())
|
||||
t.Logf("setting cookie %s", cookie)
|
||||
}
|
||||
|
||||
var accessToken, refreshToken, idToken, email string
|
||||
redirect := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens, state string, rp rp.RelyingParty, info oidc.UserInfo) {
|
||||
require.NotNil(t, tokens, "tokens")
|
||||
require.NotNil(t, info, "info")
|
||||
t.Log("access token", tokens.AccessToken)
|
||||
t.Log("refresh token", tokens.RefreshToken)
|
||||
t.Log("id token", tokens.IDToken)
|
||||
t.Log("email", info.GetEmail())
|
||||
|
||||
accessToken = tokens.AccessToken
|
||||
refreshToken = tokens.RefreshToken
|
||||
idToken = tokens.IDToken
|
||||
email = info.GetEmail()
|
||||
http.Redirect(w, r, targetURL, 302)
|
||||
}
|
||||
rp.CodeExchangeHandler(rp.UserinfoCallback(redirect), provider, rp.WithURLParam("custom", "param"))(capturedW, get)
|
||||
|
||||
defer func() {
|
||||
if t.Failed() {
|
||||
t.Log("token exchange response body", capturedW.Body.String())
|
||||
require.GreaterOrEqual(t, capturedW.Code, 200, "captured response code")
|
||||
}
|
||||
}()
|
||||
require.Less(t, capturedW.Code, 400, "token exchange response code")
|
||||
require.Less(t, capturedW.Code, 400, "token exchange response code")
|
||||
// TODO: how to check the custom header was sent to the server?
|
||||
|
||||
//nolint:bodyclose
|
||||
resp = capturedW.Result()
|
||||
|
||||
authorizedURL, err := resp.Location()
|
||||
require.NoError(t, err, "get fully-authorizied redirect location")
|
||||
require.Equal(t, targetURL, authorizedURL.String(), "fully-authorizied redirect location")
|
||||
|
||||
require.NotEmpty(t, idToken, "id token")
|
||||
assert.NotEmpty(t, refreshToken, "refresh token")
|
||||
assert.NotEmpty(t, accessToken, "access token")
|
||||
assert.NotEmpty(t, email, "email")
|
||||
|
||||
t.Log("------- refresh tokens ------")
|
||||
|
||||
newTokens, err := rp.RefreshAccessToken(provider, refreshToken, "", "")
|
||||
require.NoError(t, err, "refresh token")
|
||||
assert.NotNil(t, newTokens, "access token")
|
||||
t.Logf("new access token %s", newTokens.AccessToken)
|
||||
t.Logf("new refresh token %s", newTokens.RefreshToken)
|
||||
t.Logf("new token type %s", newTokens.TokenType)
|
||||
t.Logf("new expiry %s", newTokens.Expiry.Format(time.RFC3339))
|
||||
require.NotEmpty(t, newTokens.AccessToken, "new accessToken")
|
||||
|
||||
t.Log("------ end session (logout) ------")
|
||||
|
||||
newLoc, err := rp.EndSession(provider, idToken, "", "")
|
||||
require.NoError(t, err, "logout")
|
||||
if newLoc != nil {
|
||||
t.Logf("redirect to %s", newLoc)
|
||||
} else {
|
||||
t.Logf("no redirect")
|
||||
}
|
||||
|
||||
t.Log("------ attempt refresh again (should fail) ------")
|
||||
t.Log("trying original refresh token", refreshToken)
|
||||
_, err = rp.RefreshAccessToken(provider, refreshToken, "", "")
|
||||
assert.Errorf(t, err, "refresh with original")
|
||||
if newTokens.RefreshToken != "" {
|
||||
t.Log("trying replacement refresh token", newTokens.RefreshToken)
|
||||
_, err = rp.RefreshAccessToken(provider, newTokens.RefreshToken, "", "")
|
||||
assert.Errorf(t, err, "refresh with replacement")
|
||||
}
|
||||
|
||||
t.Run("WithPrompt", func(t *testing.T) {
|
||||
opts := rp.WithPrompt("foo", "bar")()
|
||||
url := provider.OAuthConfig().AuthCodeURL("some", opts...)
|
||||
|
||||
require.Contains(t, url, "prompt=foo+bar")
|
||||
})
|
||||
}
|
||||
|
||||
type deferredHandler struct {
|
||||
http.Handler
|
||||
}
|
||||
|
||||
func getRedirect(t *testing.T, desc string, httpClient *http.Client, uri *url.URL) *url.URL {
|
||||
req := &http.Request{
|
||||
Method: "GET",
|
||||
URL: uri,
|
||||
Header: make(http.Header),
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
require.NoError(t, err, "GET "+uri.String())
|
||||
|
||||
defer func() {
|
||||
if t.Failed() {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Logf("%s: GET %s: body: %s", desc, uri, string(body))
|
||||
}
|
||||
}()
|
||||
|
||||
//nolint:errcheck
|
||||
defer resp.Body.Close()
|
||||
redirect, err := resp.Location()
|
||||
require.NoErrorf(t, err, "%s: get redirect %s", desc, uri)
|
||||
require.NotEmptyf(t, redirect, "%s: get redirect %s", desc, uri)
|
||||
return redirect
|
||||
}
|
||||
|
||||
func getForm(t *testing.T, desc string, httpClient *http.Client, uri *url.URL) []byte {
|
||||
req := &http.Request{
|
||||
Method: "GET",
|
||||
URL: uri,
|
||||
Header: make(http.Header),
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
require.NoErrorf(t, err, "%s: GET %s", desc, uri)
|
||||
//nolint:errcheck
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err, "%s: read GET %s", desc, uri)
|
||||
return body
|
||||
}
|
||||
|
||||
func fillForm(t *testing.T, desc string, httpClient *http.Client, body []byte, uri *url.URL, opts ...gosubmit.Option) *url.URL {
|
||||
// TODO: switch to io.NopCloser when go1.15 support is dropped
|
||||
req := gosubmit.ParseWithURL(ioutil.NopCloser(bytes.NewReader(body)), uri.String()).FirstForm().Testing(t).NewTestRequest(
|
||||
append([]gosubmit.Option{gosubmit.AutoFill()}, opts...)...,
|
||||
)
|
||||
if req.URL.Scheme == "" {
|
||||
req.URL = uri
|
||||
t.Log("request lost it's proto..., adding back... request now", req.URL)
|
||||
}
|
||||
req.RequestURI = "" // bug in gosubmit?
|
||||
resp, err := httpClient.Do(req)
|
||||
require.NoErrorf(t, err, "%s: POST %s", desc, uri)
|
||||
|
||||
//nolint:errcheck
|
||||
defer resp.Body.Close()
|
||||
defer func() {
|
||||
if t.Failed() {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Logf("%s: GET %s: body: %s", desc, uri, string(body))
|
||||
}
|
||||
}()
|
||||
|
||||
redirect, err := resp.Location()
|
||||
require.NoErrorf(t, err, "%s: redirect for POST %s", desc, uri)
|
||||
return redirect
|
||||
}
|
|
@ -7,11 +7,10 @@ import (
|
|||
"net/http"
|
||||
"sync"
|
||||
|
||||
jose "github.com/go-jose/go-jose/v4"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/client"
|
||||
httphelper "git.christmann.info/LARA/zitadel-oidc/v3/pkg/http"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
httphelper "github.com/zitadel/oidc/pkg/http"
|
||||
"github.com/zitadel/oidc/pkg/oidc"
|
||||
)
|
||||
|
||||
func NewRemoteKeySet(client *http.Client, jwksURL string, opts ...func(*remoteKeySet)) oidc.KeySet {
|
||||
|
@ -84,9 +83,6 @@ func (i *inflight) result() ([]jose.JSONWebKey, error) {
|
|||
}
|
||||
|
||||
func (r *remoteKeySet) VerifySignature(ctx context.Context, jws *jose.JSONWebSignature) ([]byte, error) {
|
||||
ctx, span := client.Tracer.Start(ctx, "VerifySignature")
|
||||
defer span.End()
|
||||
|
||||
keyID, alg := oidc.GetKeyIDAndAlg(jws)
|
||||
if alg == "" {
|
||||
alg = r.defaultAlg
|
||||
|
@ -139,9 +135,6 @@ func (r *remoteKeySet) exactMatch(jwkID, jwsID string) bool {
|
|||
}
|
||||
|
||||
func (r *remoteKeySet) verifySignatureRemote(ctx context.Context, jws *jose.JSONWebSignature, keyID, alg string) ([]byte, error) {
|
||||
ctx, span := client.Tracer.Start(ctx, "verifySignatureRemote")
|
||||
defer span.End()
|
||||
|
||||
keys, err := r.keysFromRemote(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to fetch key for signature validation: %w", err)
|
||||
|
@ -166,9 +159,6 @@ func (r *remoteKeySet) keysFromCache() (keys []jose.JSONWebKey) {
|
|||
// keysFromRemote syncs the key set from the remote set, records the values in the
|
||||
// cache, and returns the key set.
|
||||
func (r *remoteKeySet) keysFromRemote(ctx context.Context) ([]jose.JSONWebKey, error) {
|
||||
ctx, span := client.Tracer.Start(ctx, "keysFromRemote")
|
||||
defer span.End()
|
||||
|
||||
// Need to lock to inspect the inflight request field.
|
||||
r.mu.Lock()
|
||||
// If there's not a current inflight request, create one.
|
||||
|
@ -192,9 +182,6 @@ func (r *remoteKeySet) keysFromRemote(ctx context.Context) ([]jose.JSONWebKey, e
|
|||
}
|
||||
|
||||
func (r *remoteKeySet) updateKeys(ctx context.Context) {
|
||||
ctx, span := client.Tracer.Start(ctx, "updateKeys")
|
||||
defer span.End()
|
||||
|
||||
// Sync keys and finish inflight when that's done.
|
||||
keys, err := r.fetchRemoteKeys(ctx)
|
||||
|
||||
|
@ -214,10 +201,7 @@ func (r *remoteKeySet) updateKeys(ctx context.Context) {
|
|||
}
|
||||
|
||||
func (r *remoteKeySet) fetchRemoteKeys(ctx context.Context) ([]jose.JSONWebKey, error) {
|
||||
ctx, span := client.Tracer.Start(ctx, "fetchRemoteKeys")
|
||||
defer span.End()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", r.jwksURL, nil)
|
||||
req, err := http.NewRequest("GET", r.jwksURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: can't create request: %v", err)
|
||||
}
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
package rp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
)
|
||||
|
||||
func logCtxWithRPData(ctx context.Context, rp RelyingParty, attrs ...any) context.Context {
|
||||
logger, ok := rp.Logger(ctx)
|
||||
if !ok {
|
||||
return ctx
|
||||
}
|
||||
logger = logger.With(slog.Group("rp", attrs...))
|
||||
return logging.ToContext(ctx, logger)
|
||||
}
|
3
pkg/client/rp/mock/generate.go
Normal file
3
pkg/client/rp/mock/generate.go
Normal file
|
@ -0,0 +1,3 @@
|
|||
package mock
|
||||
|
||||
//go:generate mockgen -package mock -destination ./verifier.mock.go github.com/zitadel/oidc/pkg/rp Verifier
|
67
pkg/client/rp/mock/verifier.mock.go
Normal file
67
pkg/client/rp/mock/verifier.mock.go
Normal file
|
@ -0,0 +1,67 @@
|
|||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/zitadel/oidc/pkg/rp (interfaces: Verifier)
|
||||
|
||||
// Package mock is a generated GoMock package.
|
||||
package mock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
|
||||
"github.com/zitadel/oidc/pkg/oidc"
|
||||
)
|
||||
|
||||
// MockVerifier is a mock of Verifier interface
|
||||
type MockVerifier struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockVerifierMockRecorder
|
||||
}
|
||||
|
||||
// MockVerifierMockRecorder is the mock recorder for MockVerifier
|
||||
type MockVerifierMockRecorder struct {
|
||||
mock *MockVerifier
|
||||
}
|
||||
|
||||
// NewMockVerifier creates a new mock instance
|
||||
func NewMockVerifier(ctrl *gomock.Controller) *MockVerifier {
|
||||
mock := &MockVerifier{ctrl: ctrl}
|
||||
mock.recorder = &MockVerifierMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use
|
||||
func (m *MockVerifier) EXPECT() *MockVerifierMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Verify mocks base method
|
||||
func (m *MockVerifier) Verify(arg0 context.Context, arg1, arg2 string) (*oidc.IDTokenClaims, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Verify", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].(*oidc.IDTokenClaims)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Verify indicates an expected call of Verify
|
||||
func (mr *MockVerifierMockRecorder) Verify(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Verify", reflect.TypeOf((*MockVerifier)(nil).Verify), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// VerifyIDToken mocks base method
|
||||
func (m *MockVerifier) VerifyIDToken(arg0 context.Context, arg1 string) (*oidc.IDTokenClaims, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "VerifyIDToken", arg0, arg1)
|
||||
ret0, _ := ret[0].(*oidc.IDTokenClaims)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// VerifyIDToken indicates an expected call of VerifyIDToken
|
||||
func (mr *MockVerifierMockRecorder) VerifyIDToken(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifyIDToken", reflect.TypeOf((*MockVerifier)(nil).VerifyIDToken), arg0, arg1)
|
||||
}
|
|
@ -4,20 +4,19 @@ import (
|
|||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/client"
|
||||
httphelper "git.christmann.info/LARA/zitadel-oidc/v3/pkg/http"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
"github.com/zitadel/logging"
|
||||
"github.com/zitadel/oidc/pkg/client"
|
||||
httphelper "github.com/zitadel/oidc/pkg/http"
|
||||
"github.com/zitadel/oidc/pkg/oidc"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -55,39 +54,23 @@ type RelyingParty interface {
|
|||
GetEndSessionEndpoint() string
|
||||
|
||||
// GetRevokeEndpoint returns the endpoint to revoke a specific token
|
||||
GetRevokeEndpoint() string
|
||||
// "GetRevokeEndpoint() string" will be added in a future release
|
||||
|
||||
// UserinfoEndpoint returns the userinfo
|
||||
UserinfoEndpoint() string
|
||||
|
||||
// GetDeviceAuthorizationEndpoint returns the endpoint which can
|
||||
// be used to start a DeviceAuthorization flow.
|
||||
GetDeviceAuthorizationEndpoint() string
|
||||
|
||||
// IDTokenVerifier returns the verifier used for oidc id_token verification
|
||||
IDTokenVerifier() *IDTokenVerifier
|
||||
|
||||
// IDTokenVerifier returns the verifier interface used for oidc id_token verification
|
||||
IDTokenVerifier() IDTokenVerifier
|
||||
// ErrorHandler returns the handler used for callback errors
|
||||
|
||||
ErrorHandler() func(http.ResponseWriter, *http.Request, string, string, string)
|
||||
|
||||
// Logger from the context, or a fallback if set.
|
||||
Logger(context.Context) (logger *slog.Logger, ok bool)
|
||||
}
|
||||
|
||||
type HasUnauthorizedHandler interface {
|
||||
// UnauthorizedHandler returns the handler used for unauthorized errors
|
||||
UnauthorizedHandler() func(w http.ResponseWriter, r *http.Request, desc string, state string)
|
||||
}
|
||||
|
||||
type ErrorHandler func(w http.ResponseWriter, r *http.Request, errorType string, errorDesc string, state string)
|
||||
type UnauthorizedHandler func(w http.ResponseWriter, r *http.Request, desc string, state string)
|
||||
|
||||
var DefaultErrorHandler ErrorHandler = func(w http.ResponseWriter, r *http.Request, errorType string, errorDesc string, state string) {
|
||||
http.Error(w, errorType+": "+errorDesc, http.StatusInternalServerError)
|
||||
}
|
||||
var DefaultUnauthorizedHandler UnauthorizedHandler = func(w http.ResponseWriter, r *http.Request, desc string, state string) {
|
||||
http.Error(w, desc, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
type relyingParty struct {
|
||||
issuer string
|
||||
|
@ -96,19 +79,14 @@ type relyingParty struct {
|
|||
oauthConfig *oauth2.Config
|
||||
oauth2Only bool
|
||||
pkce bool
|
||||
useSigningAlgsFromDiscovery bool
|
||||
|
||||
httpClient *http.Client
|
||||
cookieHandler *httphelper.CookieHandler
|
||||
|
||||
oauthAuthStyle oauth2.AuthStyle
|
||||
|
||||
errorHandler func(http.ResponseWriter, *http.Request, string, string, string)
|
||||
unauthorizedHandler func(http.ResponseWriter, *http.Request, string, string)
|
||||
idTokenVerifier *IDTokenVerifier
|
||||
idTokenVerifier IDTokenVerifier
|
||||
verifierOpts []VerifierOption
|
||||
signer jose.Signer
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (rp *relyingParty) OAuthConfig() *oauth2.Config {
|
||||
|
@ -143,10 +121,6 @@ func (rp *relyingParty) UserinfoEndpoint() string {
|
|||
return rp.endpoints.UserinfoURL
|
||||
}
|
||||
|
||||
func (rp *relyingParty) GetDeviceAuthorizationEndpoint() string {
|
||||
return rp.endpoints.DeviceAuthorizationURL
|
||||
}
|
||||
|
||||
func (rp *relyingParty) GetEndSessionEndpoint() string {
|
||||
return rp.endpoints.EndSessionURL
|
||||
}
|
||||
|
@ -155,7 +129,7 @@ func (rp *relyingParty) GetRevokeEndpoint() string {
|
|||
return rp.endpoints.RevokeURL
|
||||
}
|
||||
|
||||
func (rp *relyingParty) IDTokenVerifier() *IDTokenVerifier {
|
||||
func (rp *relyingParty) IDTokenVerifier() IDTokenVerifier {
|
||||
if rp.idTokenVerifier == nil {
|
||||
rp.idTokenVerifier = NewIDTokenVerifier(rp.issuer, rp.oauthConfig.ClientID, NewRemoteKeySet(rp.httpClient, rp.endpoints.JKWsURL), rp.verifierOpts...)
|
||||
}
|
||||
|
@ -169,21 +143,6 @@ func (rp *relyingParty) ErrorHandler() func(http.ResponseWriter, *http.Request,
|
|||
return rp.errorHandler
|
||||
}
|
||||
|
||||
func (rp *relyingParty) UnauthorizedHandler() func(http.ResponseWriter, *http.Request, string, string) {
|
||||
if rp.unauthorizedHandler == nil {
|
||||
rp.unauthorizedHandler = DefaultUnauthorizedHandler
|
||||
}
|
||||
return rp.unauthorizedHandler
|
||||
}
|
||||
|
||||
func (rp *relyingParty) Logger(ctx context.Context) (logger *slog.Logger, ok bool) {
|
||||
logger, ok = logging.FromContext(ctx)
|
||||
if ok {
|
||||
return logger, ok
|
||||
}
|
||||
return rp.logger, rp.logger != nil
|
||||
}
|
||||
|
||||
// NewRelyingPartyOAuth creates an (OAuth2) RelyingParty with the given
|
||||
// OAuth2 Config and possible configOptions
|
||||
// it will use the AuthURL and TokenURL set in config
|
||||
|
@ -192,8 +151,6 @@ func NewRelyingPartyOAuth(config *oauth2.Config, options ...Option) (RelyingPart
|
|||
oauthConfig: config,
|
||||
httpClient: httphelper.DefaultHTTPClient,
|
||||
oauth2Only: true,
|
||||
unauthorizedHandler: DefaultUnauthorizedHandler,
|
||||
oauthAuthStyle: oauth2.AuthStyleAutoDetect,
|
||||
}
|
||||
|
||||
for _, optFunc := range options {
|
||||
|
@ -202,12 +159,9 @@ func NewRelyingPartyOAuth(config *oauth2.Config, options ...Option) (RelyingPart
|
|||
}
|
||||
}
|
||||
|
||||
rp.oauthConfig.Endpoint.AuthStyle = rp.oauthAuthStyle
|
||||
|
||||
// avoid races by calling these early
|
||||
_ = rp.IDTokenVerifier() // sets idTokenVerifier
|
||||
_ = rp.ErrorHandler() // sets errorHandler
|
||||
_ = rp.UnauthorizedHandler() // sets unauthorizedHandler
|
||||
|
||||
return rp, nil
|
||||
}
|
||||
|
@ -215,7 +169,7 @@ func NewRelyingPartyOAuth(config *oauth2.Config, options ...Option) (RelyingPart
|
|||
// NewRelyingPartyOIDC creates an (OIDC) RelyingParty with the given
|
||||
// issuer, clientID, clientSecret, redirectURI, scopes and possible configOptions
|
||||
// it will run discovery on the provided issuer and use the found endpoints
|
||||
func NewRelyingPartyOIDC(ctx context.Context, issuer, clientID, clientSecret, redirectURI string, scopes []string, options ...Option) (RelyingParty, error) {
|
||||
func NewRelyingPartyOIDC(issuer, clientID, clientSecret, redirectURI string, scopes []string, options ...Option) (RelyingParty, error) {
|
||||
rp := &relyingParty{
|
||||
issuer: issuer,
|
||||
oauthConfig: &oauth2.Config{
|
||||
|
@ -226,7 +180,6 @@ func NewRelyingPartyOIDC(ctx context.Context, issuer, clientID, clientSecret, re
|
|||
},
|
||||
httpClient: httphelper.DefaultHTTPClient,
|
||||
oauth2Only: false,
|
||||
oauthAuthStyle: oauth2.AuthStyleAutoDetect,
|
||||
}
|
||||
|
||||
for _, optFunc := range options {
|
||||
|
@ -234,25 +187,17 @@ func NewRelyingPartyOIDC(ctx context.Context, issuer, clientID, clientSecret, re
|
|||
return nil, err
|
||||
}
|
||||
}
|
||||
ctx = logCtxWithRPData(ctx, rp, "function", "NewRelyingPartyOIDC")
|
||||
discoveryConfiguration, err := client.Discover(ctx, rp.issuer, rp.httpClient, rp.DiscoveryEndpoint)
|
||||
discoveryConfiguration, err := client.Discover(rp.issuer, rp.httpClient, rp.DiscoveryEndpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rp.useSigningAlgsFromDiscovery {
|
||||
rp.verifierOpts = append(rp.verifierOpts, WithSupportedSigningAlgorithms(discoveryConfiguration.IDTokenSigningAlgValuesSupported...))
|
||||
}
|
||||
endpoints := GetEndpoints(discoveryConfiguration)
|
||||
rp.oauthConfig.Endpoint = endpoints.Endpoint
|
||||
rp.endpoints = endpoints
|
||||
|
||||
rp.oauthConfig.Endpoint.AuthStyle = rp.oauthAuthStyle
|
||||
rp.endpoints.Endpoint.AuthStyle = rp.oauthAuthStyle
|
||||
|
||||
// avoid races by calling these early
|
||||
_ = rp.IDTokenVerifier() // sets idTokenVerifier
|
||||
_ = rp.ErrorHandler() // sets errorHandler
|
||||
_ = rp.UnauthorizedHandler() // sets unauthorizedHandler
|
||||
|
||||
return rp, nil
|
||||
}
|
||||
|
@ -301,20 +246,6 @@ func WithErrorHandler(errorHandler ErrorHandler) Option {
|
|||
}
|
||||
}
|
||||
|
||||
func WithUnauthorizedHandler(unauthorizedHandler UnauthorizedHandler) Option {
|
||||
return func(rp *relyingParty) error {
|
||||
rp.unauthorizedHandler = unauthorizedHandler
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithAuthStyle(oauthAuthStyle oauth2.AuthStyle) Option {
|
||||
return func(rp *relyingParty) error {
|
||||
rp.oauthAuthStyle = oauthAuthStyle
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithVerifierOpts(opts ...VerifierOption) Option {
|
||||
return func(rp *relyingParty) error {
|
||||
rp.verifierOpts = opts
|
||||
|
@ -343,24 +274,6 @@ func WithJWTProfile(signerFromKey SignerFromKey) Option {
|
|||
}
|
||||
}
|
||||
|
||||
// WithLogger sets a logger that is used
|
||||
// in case the request context does not contain a logger.
|
||||
func WithLogger(logger *slog.Logger) Option {
|
||||
return func(rp *relyingParty) error {
|
||||
rp.logger = logger
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithSigningAlgsFromDiscovery appends the [WithSupportedSigningAlgorithms] option to the Verifier Options.
|
||||
// The algorithms returned in the `id_token_signing_alg_values_supported` from the discovery response will be set.
|
||||
func WithSigningAlgsFromDiscovery() Option {
|
||||
return func(rp *relyingParty) error {
|
||||
rp.useSigningAlgsFromDiscovery = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type SignerFromKey func() (jose.Signer, error)
|
||||
|
||||
func SignerFromKeyPath(path string) SignerFromKey {
|
||||
|
@ -389,6 +302,26 @@ func SignerFromKeyAndKeyID(key []byte, keyID string) SignerFromKey {
|
|||
}
|
||||
}
|
||||
|
||||
// Discover calls the discovery endpoint of the provided issuer and returns the found endpoints
|
||||
//
|
||||
// deprecated: use client.Discover
|
||||
func Discover(issuer string, httpClient *http.Client) (Endpoints, error) {
|
||||
wellKnown := strings.TrimSuffix(issuer, "/") + oidc.DiscoveryEndpoint
|
||||
req, err := http.NewRequest("GET", wellKnown, nil)
|
||||
if err != nil {
|
||||
return Endpoints{}, err
|
||||
}
|
||||
discoveryConfig := new(oidc.DiscoveryConfiguration)
|
||||
err = httphelper.HttpRequest(httpClient, req, &discoveryConfig)
|
||||
if err != nil {
|
||||
return Endpoints{}, err
|
||||
}
|
||||
if discoveryConfig.Issuer != issuer {
|
||||
return Endpoints{}, oidc.ErrIssuerInvalid
|
||||
}
|
||||
return GetEndpoints(discoveryConfig), nil
|
||||
}
|
||||
|
||||
// AuthURL returns the auth request url
|
||||
// (wrapping the oauth2 `AuthCodeURL`)
|
||||
func AuthURL(state string, rp RelyingParty, opts ...AuthURLOpt) string {
|
||||
|
@ -401,7 +334,7 @@ func AuthURL(state string, rp RelyingParty, opts ...AuthURLOpt) string {
|
|||
|
||||
// AuthURLHandler extends the `AuthURL` method with a http redirect handler
|
||||
// including handling setting cookie for secure `state` transfer.
|
||||
// Custom parameters can optionally be set to the redirect URL.
|
||||
// Custom paramaters can optionally be set to the redirect URL.
|
||||
func AuthURLHandler(stateFn func() string, rp RelyingParty, urlParam ...URLParamOpt) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
opts := make([]AuthURLOpt, len(urlParam))
|
||||
|
@ -411,13 +344,13 @@ func AuthURLHandler(stateFn func() string, rp RelyingParty, urlParam ...URLParam
|
|||
|
||||
state := stateFn()
|
||||
if err := trySetStateCookie(w, state, rp); err != nil {
|
||||
unauthorizedError(w, r, "failed to create state cookie: "+err.Error(), state, rp)
|
||||
http.Error(w, "failed to create state cookie: "+err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if rp.IsPKCE() {
|
||||
codeChallenge, err := GenerateAndStoreCodeChallenge(w, rp)
|
||||
if err != nil {
|
||||
unauthorizedError(w, r, "failed to create code challenge: "+err.Error(), state, rp)
|
||||
http.Error(w, "failed to create code challenge: "+err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
opts = append(opts, WithCodeChallenge(codeChallenge))
|
||||
|
@ -436,94 +369,53 @@ func GenerateAndStoreCodeChallenge(w http.ResponseWriter, rp RelyingParty) (stri
|
|||
return oidc.NewSHACodeChallenge(codeVerifier), nil
|
||||
}
|
||||
|
||||
// ErrMissingIDToken is returned when an id_token was expected,
|
||||
// but not received in the token response.
|
||||
var ErrMissingIDToken = errors.New("id_token missing")
|
||||
|
||||
func verifyTokenResponse[C oidc.IDClaims](ctx context.Context, token *oauth2.Token, rp RelyingParty) (*oidc.Tokens[C], error) {
|
||||
ctx, span := client.Tracer.Start(ctx, "verifyTokenResponse")
|
||||
defer span.End()
|
||||
|
||||
if rp.IsOAuth2Only() {
|
||||
return &oidc.Tokens[C]{Token: token}, nil
|
||||
}
|
||||
idTokenString, ok := token.Extra(idTokenKey).(string)
|
||||
if !ok {
|
||||
return &oidc.Tokens[C]{Token: token}, ErrMissingIDToken
|
||||
}
|
||||
idToken, err := VerifyTokens[C](ctx, token.AccessToken, idTokenString, rp.IDTokenVerifier())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &oidc.Tokens[C]{Token: token, IDTokenClaims: idToken, IDToken: idTokenString}, nil
|
||||
}
|
||||
|
||||
// CodeExchange handles the oauth2 code exchange, extracting and validating the id_token
|
||||
// returning it parsed together with the oauth2 tokens (access, refresh)
|
||||
func CodeExchange[C oidc.IDClaims](ctx context.Context, code string, rp RelyingParty, opts ...CodeExchangeOpt) (tokens *oidc.Tokens[C], err error) {
|
||||
ctx, codeExchangeSpan := client.Tracer.Start(ctx, "CodeExchange")
|
||||
defer codeExchangeSpan.End()
|
||||
|
||||
ctx = logCtxWithRPData(ctx, rp, "function", "CodeExchange")
|
||||
func CodeExchange(ctx context.Context, code string, rp RelyingParty, opts ...CodeExchangeOpt) (tokens *oidc.Tokens, err error) {
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, rp.HttpClient())
|
||||
codeOpts := make([]oauth2.AuthCodeOption, 0)
|
||||
for _, opt := range opts {
|
||||
codeOpts = append(codeOpts, opt()...)
|
||||
}
|
||||
|
||||
ctx, oauthExchangeSpan := client.Tracer.Start(ctx, "OAuthExchange")
|
||||
token, err := rp.OAuthConfig().Exchange(ctx, code, codeOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
oauthExchangeSpan.End()
|
||||
return verifyTokenResponse[C](ctx, token, rp)
|
||||
|
||||
if rp.IsOAuth2Only() {
|
||||
return &oidc.Tokens{Token: token}, nil
|
||||
}
|
||||
|
||||
// ClientCredentials requests an access token using the `client_credentials` grant,
|
||||
// as defined in [RFC 6749, section 4.4].
|
||||
//
|
||||
// As there is no user associated to the request an ID Token can never be returned.
|
||||
// Client Credentials are undefined in OpenID Connect and is a pure OAuth2 grant.
|
||||
// Furthermore the server SHOULD NOT return a refresh token.
|
||||
//
|
||||
// [RFC 6749, section 4.4]: https://datatracker.ietf.org/doc/html/rfc6749#section-4.4
|
||||
func ClientCredentials(ctx context.Context, rp RelyingParty, endpointParams url.Values) (token *oauth2.Token, err error) {
|
||||
ctx = logCtxWithRPData(ctx, rp, "function", "ClientCredentials")
|
||||
ctx, span := client.Tracer.Start(ctx, "ClientCredentials")
|
||||
defer span.End()
|
||||
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, rp.HttpClient())
|
||||
config := clientcredentials.Config{
|
||||
ClientID: rp.OAuthConfig().ClientID,
|
||||
ClientSecret: rp.OAuthConfig().ClientSecret,
|
||||
TokenURL: rp.OAuthConfig().Endpoint.TokenURL,
|
||||
Scopes: rp.OAuthConfig().Scopes,
|
||||
EndpointParams: endpointParams,
|
||||
AuthStyle: rp.OAuthConfig().Endpoint.AuthStyle,
|
||||
}
|
||||
return config.Token(ctx)
|
||||
idTokenString, ok := token.Extra(idTokenKey).(string)
|
||||
if !ok {
|
||||
return nil, errors.New("id_token missing")
|
||||
}
|
||||
|
||||
type CodeExchangeCallback[C oidc.IDClaims] func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[C], state string, rp RelyingParty)
|
||||
idToken, err := VerifyTokens(ctx, token.AccessToken, idTokenString, rp.IDTokenVerifier())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &oidc.Tokens{Token: token, IDTokenClaims: idToken, IDToken: idTokenString}, nil
|
||||
}
|
||||
|
||||
type CodeExchangeCallback func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens, state string, rp RelyingParty)
|
||||
|
||||
// CodeExchangeHandler extends the `CodeExchange` method with a http handler
|
||||
// including cookie handling for secure `state` transfer
|
||||
// and optional PKCE code verifier checking.
|
||||
// Custom parameters can optionally be set to the token URL.
|
||||
func CodeExchangeHandler[C oidc.IDClaims](callback CodeExchangeCallback[C], rp RelyingParty, urlParam ...URLParamOpt) http.HandlerFunc {
|
||||
// Custom paramaters can optionally be set to the token URL.
|
||||
func CodeExchangeHandler(callback CodeExchangeCallback, rp RelyingParty, urlParam ...URLParamOpt) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, span := client.Tracer.Start(r.Context(), "CodeExchangeHandler")
|
||||
r = r.WithContext(ctx)
|
||||
defer span.End()
|
||||
|
||||
state, err := tryReadStateCookie(w, r, rp)
|
||||
if err != nil {
|
||||
unauthorizedError(w, r, "failed to get state: "+err.Error(), state, rp)
|
||||
http.Error(w, "failed to get state: "+err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if errValue := r.FormValue("error"); errValue != "" {
|
||||
rp.ErrorHandler()(w, r, errValue, r.FormValue("error_description"), state)
|
||||
params := r.URL.Query()
|
||||
if params.Get("error") != "" {
|
||||
rp.ErrorHandler()(w, r, params.Get("error"), params.Get("error_description"), state)
|
||||
return
|
||||
}
|
||||
codeOpts := make([]CodeExchangeOpt, len(urlParam))
|
||||
|
@ -534,75 +426,57 @@ func CodeExchangeHandler[C oidc.IDClaims](callback CodeExchangeCallback[C], rp R
|
|||
if rp.IsPKCE() {
|
||||
codeVerifier, err := rp.CookieHandler().CheckCookie(r, pkceCode)
|
||||
if err != nil {
|
||||
unauthorizedError(w, r, "failed to get code verifier: "+err.Error(), state, rp)
|
||||
http.Error(w, "failed to get code verifier: "+err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
codeOpts = append(codeOpts, WithCodeVerifier(codeVerifier))
|
||||
rp.CookieHandler().DeleteCookie(w, pkceCode)
|
||||
}
|
||||
if rp.Signer() != nil {
|
||||
assertion, err := client.SignedJWTProfileAssertion(rp.OAuthConfig().ClientID, []string{rp.Issuer(), rp.OAuthConfig().Endpoint.TokenURL}, time.Hour, rp.Signer())
|
||||
assertion, err := client.SignedJWTProfileAssertion(rp.OAuthConfig().ClientID, []string{rp.Issuer()}, time.Hour, rp.Signer())
|
||||
if err != nil {
|
||||
unauthorizedError(w, r, "failed to build assertion: "+err.Error(), state, rp)
|
||||
http.Error(w, "failed to build assertion: "+err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
codeOpts = append(codeOpts, WithClientAssertionJWT(assertion))
|
||||
}
|
||||
tokens, err := CodeExchange[C](r.Context(), r.FormValue("code"), rp, codeOpts...)
|
||||
tokens, err := CodeExchange(r.Context(), params.Get("code"), rp, codeOpts...)
|
||||
if err != nil {
|
||||
unauthorizedError(w, r, "failed to exchange token: "+err.Error(), state, rp)
|
||||
http.Error(w, "failed to exchange token: "+err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
callback(w, r, tokens, state, rp)
|
||||
}
|
||||
}
|
||||
|
||||
type SubjectGetter interface {
|
||||
GetSubject() string
|
||||
}
|
||||
|
||||
type CodeExchangeUserinfoCallback[C oidc.IDClaims, U SubjectGetter] func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[C], state string, provider RelyingParty, info U)
|
||||
type CodeExchangeUserinfoCallback func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens, state string, provider RelyingParty, info oidc.UserInfo)
|
||||
|
||||
// UserinfoCallback wraps the callback function of the CodeExchangeHandler
|
||||
// and calls the userinfo endpoint with the access token
|
||||
// on success it will pass the userinfo into its callback function as well
|
||||
func UserinfoCallback[C oidc.IDClaims, U SubjectGetter](f CodeExchangeUserinfoCallback[C, U]) CodeExchangeCallback[C] {
|
||||
return func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[C], state string, rp RelyingParty) {
|
||||
ctx, span := client.Tracer.Start(r.Context(), "UserinfoCallback")
|
||||
r = r.WithContext(ctx)
|
||||
defer span.End()
|
||||
|
||||
info, err := Userinfo[U](r.Context(), tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.GetSubject(), rp)
|
||||
func UserinfoCallback(f CodeExchangeUserinfoCallback) CodeExchangeCallback {
|
||||
return func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens, state string, rp RelyingParty) {
|
||||
info, err := Userinfo(tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.GetSubject(), rp)
|
||||
if err != nil {
|
||||
unauthorizedError(w, r, "userinfo failed: "+err.Error(), state, rp)
|
||||
http.Error(w, "userinfo failed: "+err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
f(w, r, tokens, state, rp, info)
|
||||
}
|
||||
}
|
||||
|
||||
// Userinfo will call the OIDC [UserInfo] Endpoint with the provided token and returns
|
||||
// the response in an instance of type U.
|
||||
// [*oidc.UserInfo] can be used as a good example, or use a custom type if type-safe
|
||||
// access to custom claims is needed.
|
||||
//
|
||||
// [UserInfo]: https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
|
||||
func Userinfo[U SubjectGetter](ctx context.Context, token, tokenType, subject string, rp RelyingParty) (userinfo U, err error) {
|
||||
var nilU U
|
||||
ctx = logCtxWithRPData(ctx, rp, "function", "Userinfo")
|
||||
ctx, span := client.Tracer.Start(ctx, "Userinfo")
|
||||
defer span.End()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rp.UserinfoEndpoint(), nil)
|
||||
// Userinfo will call the OIDC Userinfo Endpoint with the provided token
|
||||
func Userinfo(token, tokenType, subject string, rp RelyingParty) (oidc.UserInfo, error) {
|
||||
req, err := http.NewRequest("GET", rp.UserinfoEndpoint(), nil)
|
||||
if err != nil {
|
||||
return nilU, err
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("authorization", tokenType+" "+token)
|
||||
userinfo := oidc.NewUserInfo()
|
||||
if err := httphelper.HttpRequest(rp.HttpClient(), req, &userinfo); err != nil {
|
||||
return nilU, err
|
||||
return nil, err
|
||||
}
|
||||
if userinfo.GetSubject() != subject {
|
||||
return nilU, ErrUserInfoSubNotMatching
|
||||
return nil, ErrUserInfoSubNotMatching
|
||||
}
|
||||
return userinfo, nil
|
||||
}
|
||||
|
@ -637,13 +511,13 @@ type Endpoints struct {
|
|||
JKWsURL string
|
||||
EndSessionURL string
|
||||
RevokeURL string
|
||||
DeviceAuthorizationURL string
|
||||
}
|
||||
|
||||
func GetEndpoints(discoveryConfig *oidc.DiscoveryConfiguration) Endpoints {
|
||||
return Endpoints{
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: discoveryConfig.AuthorizationEndpoint,
|
||||
AuthStyle: oauth2.AuthStyleAutoDetect,
|
||||
TokenURL: discoveryConfig.TokenEndpoint,
|
||||
},
|
||||
IntrospectURL: discoveryConfig.IntrospectionEndpoint,
|
||||
|
@ -651,11 +525,10 @@ func GetEndpoints(discoveryConfig *oidc.DiscoveryConfiguration) Endpoints {
|
|||
JKWsURL: discoveryConfig.JwksURI,
|
||||
EndSessionURL: discoveryConfig.EndSessionEndpoint,
|
||||
RevokeURL: discoveryConfig.RevocationEndpoint,
|
||||
DeviceAuthorizationURL: discoveryConfig.DeviceAuthorizationEndpoint,
|
||||
}
|
||||
}
|
||||
|
||||
// withURLParam sets custom url parameters.
|
||||
// withURLParam sets custom url paramaters.
|
||||
// This is the generalized, unexported, function used by both
|
||||
// URLParamOpt and AuthURLOpt.
|
||||
func withURLParam(key, value string) func() []oauth2.AuthCodeOption {
|
||||
|
@ -670,7 +543,7 @@ func withURLParam(key, value string) func() []oauth2.AuthCodeOption {
|
|||
// This is the generalized, unexported, function used by both
|
||||
// URLParamOpt and AuthURLOpt.
|
||||
func withPrompt(prompt ...string) func() []oauth2.AuthCodeOption {
|
||||
return withURLParam("prompt", oidc.SpaceDelimitedArray(prompt).String())
|
||||
return withURLParam("prompt", oidc.SpaceDelimitedArray(prompt).Encode())
|
||||
}
|
||||
|
||||
type URLParamOpt func() []oauth2.AuthCodeOption
|
||||
|
@ -686,11 +559,6 @@ func WithPromptURLParam(prompt ...string) URLParamOpt {
|
|||
return withPrompt(prompt...)
|
||||
}
|
||||
|
||||
// WithResponseModeURLParam sets the `response_mode` parameter in a URL.
|
||||
func WithResponseModeURLParam(mode oidc.ResponseMode) URLParamOpt {
|
||||
return withURLParam("response_mode", string(mode))
|
||||
}
|
||||
|
||||
type AuthURLOpt func() []oauth2.AuthCodeOption
|
||||
|
||||
// WithCodeChallenge sets the `code_challenge` params in the auth request
|
||||
|
@ -734,26 +602,15 @@ func (t tokenEndpointCaller) TokenEndpoint() string {
|
|||
|
||||
type RefreshTokenRequest struct {
|
||||
RefreshToken string `schema:"refresh_token"`
|
||||
Scopes oidc.SpaceDelimitedArray `schema:"scope,omitempty"`
|
||||
ClientID string `schema:"client_id,omitempty"`
|
||||
ClientSecret string `schema:"client_secret,omitempty"`
|
||||
ClientAssertion string `schema:"client_assertion,omitempty"`
|
||||
ClientAssertionType string `schema:"client_assertion_type,omitempty"`
|
||||
Scopes oidc.SpaceDelimitedArray `schema:"scope"`
|
||||
ClientID string `schema:"client_id"`
|
||||
ClientSecret string `schema:"client_secret"`
|
||||
ClientAssertion string `schema:"client_assertion"`
|
||||
ClientAssertionType string `schema:"client_assertion_type"`
|
||||
GrantType oidc.GrantType `schema:"grant_type"`
|
||||
}
|
||||
|
||||
// RefreshTokens performs a token refresh. If it doesn't error, it will always
|
||||
// provide a new AccessToken. It may provide a new RefreshToken, and if it does, then
|
||||
// the old one should be considered invalid.
|
||||
//
|
||||
// In case the RP is not OAuth2 only and an IDToken was part of the response,
|
||||
// the IDToken and AccessToken will be verified
|
||||
// and the IDToken and IDTokenClaims fields will be populated in the returned object.
|
||||
func RefreshTokens[C oidc.IDClaims](ctx context.Context, rp RelyingParty, refreshToken, clientAssertion, clientAssertionType string) (*oidc.Tokens[C], error) {
|
||||
ctx, span := client.Tracer.Start(ctx, "RefreshTokens")
|
||||
defer span.End()
|
||||
|
||||
ctx = logCtxWithRPData(ctx, rp, "function", "RefreshTokens")
|
||||
func RefreshAccessToken(rp RelyingParty, refreshToken, clientAssertion, clientAssertionType string) (*oauth2.Token, error) {
|
||||
request := RefreshTokenRequest{
|
||||
RefreshToken: refreshToken,
|
||||
Scopes: rp.OAuthConfig().Scopes,
|
||||
|
@ -763,31 +620,17 @@ func RefreshTokens[C oidc.IDClaims](ctx context.Context, rp RelyingParty, refres
|
|||
ClientAssertionType: clientAssertionType,
|
||||
GrantType: oidc.GrantTypeRefreshToken,
|
||||
}
|
||||
newToken, err := client.CallTokenEndpoint(ctx, request, tokenEndpointCaller{RelyingParty: rp})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokens, err := verifyTokenResponse[C](ctx, newToken, rp)
|
||||
if err == nil || errors.Is(err, ErrMissingIDToken) {
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse
|
||||
// ...except that it might not contain an id_token.
|
||||
return tokens, nil
|
||||
}
|
||||
return nil, err
|
||||
return client.CallTokenEndpoint(request, tokenEndpointCaller{RelyingParty: rp})
|
||||
}
|
||||
|
||||
func EndSession(ctx context.Context, rp RelyingParty, idToken, optionalRedirectURI, optionalState string) (*url.URL, error) {
|
||||
ctx = logCtxWithRPData(ctx, rp, "function", "EndSession")
|
||||
ctx, span := client.Tracer.Start(ctx, "RefreshTokens")
|
||||
defer span.End()
|
||||
|
||||
func EndSession(rp RelyingParty, idToken, optionalRedirectURI, optionalState string) (*url.URL, error) {
|
||||
request := oidc.EndSessionRequest{
|
||||
IdTokenHint: idToken,
|
||||
ClientID: rp.OAuthConfig().ClientID,
|
||||
PostLogoutRedirectURI: optionalRedirectURI,
|
||||
State: optionalState,
|
||||
}
|
||||
return client.CallEndSessionEndpoint(ctx, request, nil, rp)
|
||||
return client.CallEndSessionEndpoint(request, nil, rp)
|
||||
}
|
||||
|
||||
// RevokeToken requires a RelyingParty that is also a client.RevokeCaller. The RelyingParty
|
||||
|
@ -795,10 +638,7 @@ func EndSession(ctx context.Context, rp RelyingParty, idToken, optionalRedirectU
|
|||
// NewRelyingPartyOAuth() does not.
|
||||
//
|
||||
// tokenTypeHint should be either "id_token" or "refresh_token".
|
||||
func RevokeToken(ctx context.Context, rp RelyingParty, token string, tokenTypeHint string) error {
|
||||
ctx = logCtxWithRPData(ctx, rp, "function", "RevokeToken")
|
||||
ctx, span := client.Tracer.Start(ctx, "RefreshTokens")
|
||||
defer span.End()
|
||||
func RevokeToken(rp RelyingParty, token string, tokenTypeHint string) error {
|
||||
request := client.RevokeRequest{
|
||||
Token: token,
|
||||
TokenTypeHint: tokenTypeHint,
|
||||
|
@ -806,15 +646,7 @@ func RevokeToken(ctx context.Context, rp RelyingParty, token string, tokenTypeHi
|
|||
ClientSecret: rp.OAuthConfig().ClientSecret,
|
||||
}
|
||||
if rc, ok := rp.(client.RevokeCaller); ok && rc.GetRevokeEndpoint() != "" {
|
||||
return client.CallRevokeEndpoint(ctx, request, nil, rc)
|
||||
return client.CallRevokeEndpoint(request, nil, rc)
|
||||
}
|
||||
return ErrRelyingPartyNotSupportRevokeCaller
|
||||
}
|
||||
|
||||
func unauthorizedError(w http.ResponseWriter, r *http.Request, desc string, state string, rp RelyingParty) {
|
||||
if rp, ok := rp.(HasUnauthorizedHandler); ok {
|
||||
rp.UnauthorizedHandler()(w, r, desc, state)
|
||||
return
|
||||
}
|
||||
http.Error(w, desc, http.StatusUnauthorized)
|
||||
return fmt.Errorf("RelyingParty does not support RevokeCaller")
|
||||
}
|
||||
|
|
|
@ -1,107 +0,0 @@
|
|||
package rp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tu "git.christmann.info/LARA/zitadel-oidc/v3/internal/testutil"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
func Test_verifyTokenResponse(t *testing.T) {
|
||||
verifier := &IDTokenVerifier{
|
||||
Issuer: tu.ValidIssuer,
|
||||
MaxAgeIAT: 2 * time.Minute,
|
||||
ClientID: tu.ValidClientID,
|
||||
Offset: time.Second,
|
||||
SupportedSignAlgs: []string{string(tu.SignatureAlgorithm)},
|
||||
KeySet: tu.KeySet{},
|
||||
MaxAge: 2 * time.Minute,
|
||||
ACR: tu.ACRVerify,
|
||||
Nonce: func(context.Context) string { return tu.ValidNonce },
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
oauth2Only bool
|
||||
tokens func() (token *oauth2.Token, want *oidc.Tokens[*oidc.IDTokenClaims])
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "succes, oauth2 only",
|
||||
oauth2Only: true,
|
||||
tokens: func() (*oauth2.Token, *oidc.Tokens[*oidc.IDTokenClaims]) {
|
||||
accesToken, _ := tu.ValidAccessToken()
|
||||
token := &oauth2.Token{
|
||||
AccessToken: accesToken,
|
||||
}
|
||||
return token, &oidc.Tokens[*oidc.IDTokenClaims]{
|
||||
Token: token,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "id_token missing error",
|
||||
oauth2Only: false,
|
||||
tokens: func() (*oauth2.Token, *oidc.Tokens[*oidc.IDTokenClaims]) {
|
||||
accesToken, _ := tu.ValidAccessToken()
|
||||
token := &oauth2.Token{
|
||||
AccessToken: accesToken,
|
||||
}
|
||||
return token, &oidc.Tokens[*oidc.IDTokenClaims]{
|
||||
Token: token,
|
||||
}
|
||||
},
|
||||
wantErr: ErrMissingIDToken,
|
||||
},
|
||||
{
|
||||
name: "verify tokens error",
|
||||
oauth2Only: false,
|
||||
tokens: func() (*oauth2.Token, *oidc.Tokens[*oidc.IDTokenClaims]) {
|
||||
accesToken, _ := tu.ValidAccessToken()
|
||||
token := &oauth2.Token{
|
||||
AccessToken: accesToken,
|
||||
}
|
||||
token = token.WithExtra(map[string]any{
|
||||
"id_token": "foobar",
|
||||
})
|
||||
return token, nil
|
||||
},
|
||||
wantErr: oidc.ErrParse,
|
||||
},
|
||||
{
|
||||
name: "success, with id_token",
|
||||
oauth2Only: false,
|
||||
tokens: func() (*oauth2.Token, *oidc.Tokens[*oidc.IDTokenClaims]) {
|
||||
accesToken, _ := tu.ValidAccessToken()
|
||||
token := &oauth2.Token{
|
||||
AccessToken: accesToken,
|
||||
}
|
||||
idToken, claims := tu.ValidIDToken()
|
||||
token = token.WithExtra(map[string]any{
|
||||
"id_token": idToken,
|
||||
})
|
||||
return token, &oidc.Tokens[*oidc.IDTokenClaims]{
|
||||
Token: token,
|
||||
IDTokenClaims: claims,
|
||||
IDToken: idToken,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rp := &relyingParty{
|
||||
oauth2Only: tt.oauth2Only,
|
||||
idTokenVerifier: verifier,
|
||||
}
|
||||
token, want := tt.tokens()
|
||||
got, err := verifyTokenResponse[*oidc.IDTokenClaims](context.Background(), token, rp)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
assert.Equal(t, want, got)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -5,7 +5,7 @@ import (
|
|||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc/grants/tokenexchange"
|
||||
"github.com/zitadel/oidc/pkg/oidc/grants/tokenexchange"
|
||||
)
|
||||
|
||||
// TokenExchangeRP extends the `RelyingParty` interface for the *draft* oauth2 `Token Exchange`
|
||||
|
|
|
@ -1,45 +0,0 @@
|
|||
package rp_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/client/rp"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
)
|
||||
|
||||
type UserInfo struct {
|
||||
Subject string `json:"sub,omitempty"`
|
||||
oidc.UserInfoProfile
|
||||
oidc.UserInfoEmail
|
||||
oidc.UserInfoPhone
|
||||
Address *oidc.UserInfoAddress `json:"address,omitempty"`
|
||||
|
||||
// Foo and Bar are custom claims
|
||||
Foo string `json:"foo,omitempty"`
|
||||
Bar struct {
|
||||
Val1 string `json:"val_1,omitempty"`
|
||||
Val2 string `json:"val_2,omitempty"`
|
||||
} `json:"bar,omitempty"`
|
||||
|
||||
// Claims are all the combined claims, including custom.
|
||||
Claims map[string]any `json:"-,omitempty"`
|
||||
}
|
||||
|
||||
func (u *UserInfo) GetSubject() string {
|
||||
return u.Subject
|
||||
}
|
||||
|
||||
func ExampleUserinfo_custom() {
|
||||
rpo, err := rp.NewRelyingPartyOIDC(context.TODO(), "http://localhost:8080", "clientid", "clientsecret", "http://example.com/redirect", []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopePhone})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
info, err := rp.Userinfo[*UserInfo](context.TODO(), "accesstokenstring", "Bearer", "userid", rpo)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Println(info)
|
||||
}
|
|
@ -4,93 +4,90 @@ import (
|
|||
"context"
|
||||
"time"
|
||||
|
||||
jose "github.com/go-jose/go-jose/v4"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/client"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
"github.com/zitadel/oidc/pkg/oidc"
|
||||
)
|
||||
|
||||
type IDTokenVerifier interface {
|
||||
oidc.Verifier
|
||||
ClientID() string
|
||||
SupportedSignAlgs() []string
|
||||
KeySet() oidc.KeySet
|
||||
Nonce(context.Context) string
|
||||
ACR() oidc.ACRVerifier
|
||||
MaxAge() time.Duration
|
||||
}
|
||||
|
||||
// VerifyTokens implement the Token Response Validation as defined in OIDC specification
|
||||
//https://openid.net/specs/openid-connect-core-1_0.html#TokenResponseValidation
|
||||
func VerifyTokens[C oidc.IDClaims](ctx context.Context, accessToken, idToken string, v *IDTokenVerifier) (claims C, err error) {
|
||||
ctx, span := client.Tracer.Start(ctx, "VerifyTokens")
|
||||
defer span.End()
|
||||
|
||||
var nilClaims C
|
||||
|
||||
claims, err = VerifyIDToken[C](ctx, idToken, v)
|
||||
func VerifyTokens(ctx context.Context, accessToken, idTokenString string, v IDTokenVerifier) (oidc.IDTokenClaims, error) {
|
||||
idToken, err := VerifyIDToken(ctx, idTokenString, v)
|
||||
if err != nil {
|
||||
return nilClaims, err
|
||||
return nil, err
|
||||
}
|
||||
if err := VerifyAccessToken(accessToken, claims.GetAccessTokenHash(), claims.GetSignatureAlgorithm()); err != nil {
|
||||
return nilClaims, err
|
||||
if err := VerifyAccessToken(accessToken, idToken.GetAccessTokenHash(), idToken.GetSignatureAlgorithm()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return claims, nil
|
||||
return idToken, nil
|
||||
}
|
||||
|
||||
// VerifyIDToken validates the id token according to
|
||||
//https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
|
||||
func VerifyIDToken[C oidc.Claims](ctx context.Context, token string, v *IDTokenVerifier) (claims C, err error) {
|
||||
ctx, span := client.Tracer.Start(ctx, "VerifyIDToken")
|
||||
defer span.End()
|
||||
|
||||
var nilClaims C
|
||||
func VerifyIDToken(ctx context.Context, token string, v IDTokenVerifier) (oidc.IDTokenClaims, error) {
|
||||
claims := oidc.EmptyIDTokenClaims()
|
||||
|
||||
decrypted, err := oidc.DecryptToken(token)
|
||||
if err != nil {
|
||||
return nilClaims, err
|
||||
return nil, err
|
||||
}
|
||||
payload, err := oidc.ParseToken(decrypted, &claims)
|
||||
payload, err := oidc.ParseToken(decrypted, claims)
|
||||
if err != nil {
|
||||
return nilClaims, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := oidc.CheckSubject(claims); err != nil {
|
||||
return nilClaims, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = oidc.CheckIssuer(claims, v.Issuer); err != nil {
|
||||
return nilClaims, err
|
||||
if err = oidc.CheckIssuer(claims, v.Issuer()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = oidc.CheckAudience(claims, v.ClientID); err != nil {
|
||||
return nilClaims, err
|
||||
if err = oidc.CheckAudience(claims, v.ClientID()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = oidc.CheckAuthorizedParty(claims, v.ClientID); err != nil {
|
||||
return nilClaims, err
|
||||
if err = oidc.CheckAuthorizedParty(claims, v.ClientID()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = oidc.CheckSignature(ctx, decrypted, payload, claims, v.SupportedSignAlgs, v.KeySet); err != nil {
|
||||
return nilClaims, err
|
||||
if err = oidc.CheckSignature(ctx, decrypted, payload, claims, v.SupportedSignAlgs(), v.KeySet()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = oidc.CheckExpiration(claims, v.Offset); err != nil {
|
||||
return nilClaims, err
|
||||
if err = oidc.CheckExpiration(claims, v.Offset()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = oidc.CheckIssuedAt(claims, v.MaxAgeIAT, v.Offset); err != nil {
|
||||
return nilClaims, err
|
||||
if err = oidc.CheckIssuedAt(claims, v.MaxAgeIAT(), v.Offset()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if v.Nonce != nil {
|
||||
if err = oidc.CheckNonce(claims, v.Nonce(ctx)); err != nil {
|
||||
return nilClaims, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = oidc.CheckAuthorizationContextClassReference(claims, v.ACR); err != nil {
|
||||
return nilClaims, err
|
||||
if err = oidc.CheckAuthorizationContextClassReference(claims, v.ACR()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = oidc.CheckAuthTime(claims, v.MaxAge); err != nil {
|
||||
return nilClaims, err
|
||||
if err = oidc.CheckAuthTime(claims, v.MaxAge()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
type IDTokenVerifier oidc.Verifier
|
||||
|
||||
// VerifyAccessToken validates the access token according to
|
||||
//https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowTokenValidation
|
||||
func VerifyAccessToken(accessToken, atHash string, sigAlgorithm jose.SignatureAlgorithm) error {
|
||||
|
@ -108,14 +105,15 @@ func VerifyAccessToken(accessToken, atHash string, sigAlgorithm jose.SignatureAl
|
|||
return nil
|
||||
}
|
||||
|
||||
// NewIDTokenVerifier returns a oidc.Verifier suitable for ID token verification.
|
||||
func NewIDTokenVerifier(issuer, clientID string, keySet oidc.KeySet, options ...VerifierOption) *IDTokenVerifier {
|
||||
v := &IDTokenVerifier{
|
||||
Issuer: issuer,
|
||||
ClientID: clientID,
|
||||
KeySet: keySet,
|
||||
Offset: time.Second,
|
||||
Nonce: func(_ context.Context) string {
|
||||
// NewIDTokenVerifier returns an implementation of `IDTokenVerifier`
|
||||
// for `VerifyTokens` and `VerifyIDToken`
|
||||
func NewIDTokenVerifier(issuer, clientID string, keySet oidc.KeySet, options ...VerifierOption) IDTokenVerifier {
|
||||
v := &idTokenVerifier{
|
||||
issuer: issuer,
|
||||
clientID: clientID,
|
||||
keySet: keySet,
|
||||
offset: 1 * time.Second,
|
||||
nonce: func(_ context.Context) string {
|
||||
return ""
|
||||
},
|
||||
}
|
||||
|
@ -128,47 +126,95 @@ func NewIDTokenVerifier(issuer, clientID string, keySet oidc.KeySet, options ...
|
|||
}
|
||||
|
||||
// VerifierOption is the type for providing dynamic options to the IDTokenVerifier
|
||||
type VerifierOption func(*IDTokenVerifier)
|
||||
type VerifierOption func(*idTokenVerifier)
|
||||
|
||||
// WithIssuedAtOffset mitigates the risk of iat to be in the future
|
||||
// because of clock skews with the ability to add an offset to the current time
|
||||
func WithIssuedAtOffset(offset time.Duration) VerifierOption {
|
||||
return func(v *IDTokenVerifier) {
|
||||
v.Offset = offset
|
||||
func WithIssuedAtOffset(offset time.Duration) func(*idTokenVerifier) {
|
||||
return func(v *idTokenVerifier) {
|
||||
v.offset = offset
|
||||
}
|
||||
}
|
||||
|
||||
// WithIssuedAtMaxAge provides the ability to define the maximum duration between iat and now
|
||||
func WithIssuedAtMaxAge(maxAge time.Duration) VerifierOption {
|
||||
return func(v *IDTokenVerifier) {
|
||||
v.MaxAgeIAT = maxAge
|
||||
func WithIssuedAtMaxAge(maxAge time.Duration) func(*idTokenVerifier) {
|
||||
return func(v *idTokenVerifier) {
|
||||
v.maxAge = maxAge
|
||||
}
|
||||
}
|
||||
|
||||
// WithNonce sets the function to check the nonce
|
||||
func WithNonce(nonce func(context.Context) string) VerifierOption {
|
||||
return func(v *IDTokenVerifier) {
|
||||
v.Nonce = nonce
|
||||
return func(v *idTokenVerifier) {
|
||||
v.nonce = nonce
|
||||
}
|
||||
}
|
||||
|
||||
// WithACRVerifier sets the verifier for the acr claim
|
||||
func WithACRVerifier(verifier oidc.ACRVerifier) VerifierOption {
|
||||
return func(v *IDTokenVerifier) {
|
||||
v.ACR = verifier
|
||||
return func(v *idTokenVerifier) {
|
||||
v.acr = verifier
|
||||
}
|
||||
}
|
||||
|
||||
// WithAuthTimeMaxAge provides the ability to define the maximum duration between auth_time and now
|
||||
func WithAuthTimeMaxAge(maxAge time.Duration) VerifierOption {
|
||||
return func(v *IDTokenVerifier) {
|
||||
v.MaxAge = maxAge
|
||||
return func(v *idTokenVerifier) {
|
||||
v.maxAge = maxAge
|
||||
}
|
||||
}
|
||||
|
||||
// WithSupportedSigningAlgorithms overwrites the default RS256 signing algorithm
|
||||
func WithSupportedSigningAlgorithms(algs ...string) VerifierOption {
|
||||
return func(v *IDTokenVerifier) {
|
||||
v.SupportedSignAlgs = algs
|
||||
return func(v *idTokenVerifier) {
|
||||
v.supportedSignAlgs = algs
|
||||
}
|
||||
}
|
||||
|
||||
type idTokenVerifier struct {
|
||||
issuer string
|
||||
maxAgeIAT time.Duration
|
||||
offset time.Duration
|
||||
clientID string
|
||||
supportedSignAlgs []string
|
||||
keySet oidc.KeySet
|
||||
acr oidc.ACRVerifier
|
||||
maxAge time.Duration
|
||||
nonce func(ctx context.Context) string
|
||||
}
|
||||
|
||||
func (i *idTokenVerifier) Issuer() string {
|
||||
return i.issuer
|
||||
}
|
||||
|
||||
func (i *idTokenVerifier) MaxAgeIAT() time.Duration {
|
||||
return i.maxAgeIAT
|
||||
}
|
||||
|
||||
func (i *idTokenVerifier) Offset() time.Duration {
|
||||
return i.offset
|
||||
}
|
||||
|
||||
func (i *idTokenVerifier) ClientID() string {
|
||||
return i.clientID
|
||||
}
|
||||
|
||||
func (i *idTokenVerifier) SupportedSignAlgs() []string {
|
||||
return i.supportedSignAlgs
|
||||
}
|
||||
|
||||
func (i *idTokenVerifier) KeySet() oidc.KeySet {
|
||||
return i.keySet
|
||||
}
|
||||
|
||||
func (i *idTokenVerifier) Nonce(ctx context.Context) string {
|
||||
return i.nonce(ctx)
|
||||
}
|
||||
|
||||
func (i *idTokenVerifier) ACR() oidc.ACRVerifier {
|
||||
return i.acr
|
||||
}
|
||||
|
||||
func (i *idTokenVerifier) MaxAge() time.Duration {
|
||||
return i.maxAge
|
||||
}
|
||||
|
|
|
@ -1,359 +0,0 @@
|
|||
package rp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tu "git.christmann.info/LARA/zitadel-oidc/v3/internal/testutil"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
jose "github.com/go-jose/go-jose/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestVerifyTokens(t *testing.T) {
|
||||
verifier := &IDTokenVerifier{
|
||||
Issuer: tu.ValidIssuer,
|
||||
MaxAgeIAT: 2 * time.Minute,
|
||||
Offset: time.Second,
|
||||
SupportedSignAlgs: []string{string(tu.SignatureAlgorithm)},
|
||||
KeySet: tu.KeySet{},
|
||||
MaxAge: 2 * time.Minute,
|
||||
ACR: tu.ACRVerify,
|
||||
Nonce: func(context.Context) string { return tu.ValidNonce },
|
||||
ClientID: tu.ValidClientID,
|
||||
}
|
||||
accessToken, _ := tu.ValidAccessToken()
|
||||
atHash, err := oidc.ClaimHash(accessToken, tu.SignatureAlgorithm)
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
accessToken string
|
||||
idTokenClaims func() (string, *oidc.IDTokenClaims)
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "without access token",
|
||||
idTokenClaims: tu.ValidIDToken,
|
||||
},
|
||||
{
|
||||
name: "with access token",
|
||||
accessToken: accessToken,
|
||||
idTokenClaims: func() (string, *oidc.IDTokenClaims) {
|
||||
return tu.NewIDToken(
|
||||
tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience,
|
||||
tu.ValidExpiration, tu.ValidAuthTime, tu.ValidNonce,
|
||||
tu.ValidACR, tu.ValidAMR, tu.ValidClientID, tu.ValidSkew, atHash,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "expired id token",
|
||||
accessToken: accessToken,
|
||||
idTokenClaims: func() (string, *oidc.IDTokenClaims) {
|
||||
return tu.NewIDToken(
|
||||
tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience,
|
||||
tu.ValidExpiration.Add(-time.Hour), tu.ValidAuthTime, tu.ValidNonce,
|
||||
tu.ValidACR, tu.ValidAMR, tu.ValidClientID, tu.ValidSkew, atHash,
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "wrong access token",
|
||||
accessToken: accessToken,
|
||||
idTokenClaims: func() (string, *oidc.IDTokenClaims) {
|
||||
return tu.NewIDToken(
|
||||
tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience,
|
||||
tu.ValidExpiration, tu.ValidAuthTime, tu.ValidNonce,
|
||||
tu.ValidACR, tu.ValidAMR, tu.ValidClientID, tu.ValidSkew, "~~~",
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
idToken, want := tt.idTokenClaims()
|
||||
got, err := VerifyTokens[*oidc.IDTokenClaims](context.Background(), tt.accessToken, idToken, verifier)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, got)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, got, want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyIDToken(t *testing.T) {
|
||||
verifier := &IDTokenVerifier{
|
||||
Issuer: tu.ValidIssuer,
|
||||
MaxAgeIAT: 2 * time.Minute,
|
||||
Offset: time.Second,
|
||||
SupportedSignAlgs: []string{string(tu.SignatureAlgorithm)},
|
||||
KeySet: tu.KeySet{},
|
||||
MaxAge: 2 * time.Minute,
|
||||
ACR: tu.ACRVerify,
|
||||
Nonce: func(context.Context) string { return tu.ValidNonce },
|
||||
ClientID: tu.ValidClientID,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tokenClaims func() (string, *oidc.IDTokenClaims)
|
||||
customVerifier func(verifier *IDTokenVerifier)
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
tokenClaims: tu.ValidIDToken,
|
||||
},
|
||||
{
|
||||
name: "custom claims",
|
||||
tokenClaims: func() (string, *oidc.IDTokenClaims) {
|
||||
return tu.NewIDTokenCustom(
|
||||
tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience,
|
||||
tu.ValidExpiration, tu.ValidAuthTime, tu.ValidNonce,
|
||||
tu.ValidACR, tu.ValidAMR, tu.ValidClientID, tu.ValidSkew, "",
|
||||
map[string]any{"some": "thing"},
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "skip nonce check",
|
||||
customVerifier: func(verifier *IDTokenVerifier) {
|
||||
verifier.Nonce = nil
|
||||
},
|
||||
tokenClaims: func() (string, *oidc.IDTokenClaims) {
|
||||
return tu.NewIDToken(
|
||||
tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience,
|
||||
tu.ValidExpiration, tu.ValidAuthTime, "foo",
|
||||
tu.ValidACR, tu.ValidAMR, tu.ValidClientID, tu.ValidSkew, "",
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "parse err",
|
||||
tokenClaims: func() (string, *oidc.IDTokenClaims) { return "~~~~", nil },
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid signature",
|
||||
tokenClaims: func() (string, *oidc.IDTokenClaims) { return tu.InvalidSignatureToken, nil },
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty subject",
|
||||
tokenClaims: func() (string, *oidc.IDTokenClaims) {
|
||||
return tu.NewIDToken(
|
||||
tu.ValidIssuer, "", tu.ValidAudience,
|
||||
tu.ValidExpiration, tu.ValidAuthTime, tu.ValidNonce,
|
||||
tu.ValidACR, tu.ValidAMR, tu.ValidClientID, tu.ValidSkew, "",
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "wrong issuer",
|
||||
tokenClaims: func() (string, *oidc.IDTokenClaims) {
|
||||
return tu.NewIDToken(
|
||||
"foo", tu.ValidSubject, tu.ValidAudience,
|
||||
tu.ValidExpiration, tu.ValidAuthTime, tu.ValidNonce,
|
||||
tu.ValidACR, tu.ValidAMR, tu.ValidClientID, tu.ValidSkew, "",
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "wrong clientID",
|
||||
customVerifier: func(verifier *IDTokenVerifier) {
|
||||
verifier.ClientID = "foo"
|
||||
},
|
||||
tokenClaims: tu.ValidIDToken,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "expired",
|
||||
tokenClaims: func() (string, *oidc.IDTokenClaims) {
|
||||
return tu.NewIDToken(
|
||||
tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience,
|
||||
tu.ValidExpiration.Add(-time.Hour), tu.ValidAuthTime, tu.ValidNonce,
|
||||
tu.ValidACR, tu.ValidAMR, tu.ValidClientID, tu.ValidSkew, "",
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "wrong IAT",
|
||||
tokenClaims: func() (string, *oidc.IDTokenClaims) {
|
||||
return tu.NewIDToken(
|
||||
tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience,
|
||||
tu.ValidExpiration, tu.ValidAuthTime, tu.ValidNonce,
|
||||
tu.ValidACR, tu.ValidAMR, tu.ValidClientID, -time.Hour, "",
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "wrong acr",
|
||||
tokenClaims: func() (string, *oidc.IDTokenClaims) {
|
||||
return tu.NewIDToken(
|
||||
tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience,
|
||||
tu.ValidExpiration, tu.ValidAuthTime, tu.ValidNonce,
|
||||
"else", tu.ValidAMR, tu.ValidClientID, tu.ValidSkew, "",
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "expired auth",
|
||||
tokenClaims: func() (string, *oidc.IDTokenClaims) {
|
||||
return tu.NewIDToken(
|
||||
tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience,
|
||||
tu.ValidExpiration, tu.ValidAuthTime.Add(-time.Hour), tu.ValidNonce,
|
||||
tu.ValidACR, tu.ValidAMR, tu.ValidClientID, tu.ValidSkew, "",
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "wrong nonce",
|
||||
tokenClaims: func() (string, *oidc.IDTokenClaims) {
|
||||
return tu.NewIDToken(
|
||||
tu.ValidIssuer, tu.ValidSubject, tu.ValidAudience,
|
||||
tu.ValidExpiration, tu.ValidAuthTime, "foo",
|
||||
tu.ValidACR, tu.ValidAMR, tu.ValidClientID, tu.ValidSkew, "",
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
token, want := tt.tokenClaims()
|
||||
if tt.customVerifier != nil {
|
||||
tt.customVerifier(verifier)
|
||||
}
|
||||
|
||||
got, err := VerifyIDToken[*oidc.IDTokenClaims](context.Background(), token, verifier)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, got)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, got, want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyAccessToken(t *testing.T) {
|
||||
token, _ := tu.ValidAccessToken()
|
||||
hash, err := oidc.ClaimHash(token, tu.SignatureAlgorithm)
|
||||
require.NoError(t, err)
|
||||
|
||||
type args struct {
|
||||
accessToken string
|
||||
atHash string
|
||||
sigAlgorithm jose.SignatureAlgorithm
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty hash",
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
args: args{
|
||||
accessToken: token,
|
||||
atHash: hash,
|
||||
sigAlgorithm: tu.SignatureAlgorithm,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid algorithm",
|
||||
args: args{
|
||||
accessToken: token,
|
||||
atHash: hash,
|
||||
sigAlgorithm: "foo",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "mismatch",
|
||||
args: args{
|
||||
accessToken: token,
|
||||
atHash: "~~",
|
||||
sigAlgorithm: tu.SignatureAlgorithm,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := VerifyAccessToken(tt.args.accessToken, tt.args.atHash, tt.args.sigAlgorithm)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIDTokenVerifier(t *testing.T) {
|
||||
type args struct {
|
||||
issuer string
|
||||
clientID string
|
||||
keySet oidc.KeySet
|
||||
options []VerifierOption
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *IDTokenVerifier
|
||||
}{
|
||||
{
|
||||
name: "nil nonce", // otherwise assert.Equal will fail on the function
|
||||
args: args{
|
||||
issuer: tu.ValidIssuer,
|
||||
clientID: tu.ValidClientID,
|
||||
keySet: tu.KeySet{},
|
||||
options: []VerifierOption{
|
||||
WithIssuedAtOffset(time.Minute),
|
||||
WithIssuedAtMaxAge(time.Hour),
|
||||
WithNonce(nil), // otherwise assert.Equal will fail on the function
|
||||
WithACRVerifier(nil),
|
||||
WithAuthTimeMaxAge(2 * time.Hour),
|
||||
WithSupportedSigningAlgorithms("ABC", "DEF"),
|
||||
},
|
||||
},
|
||||
want: &IDTokenVerifier{
|
||||
Issuer: tu.ValidIssuer,
|
||||
Offset: time.Minute,
|
||||
MaxAgeIAT: time.Hour,
|
||||
ClientID: tu.ValidClientID,
|
||||
KeySet: tu.KeySet{},
|
||||
Nonce: nil,
|
||||
ACR: nil,
|
||||
MaxAge: 2 * time.Hour,
|
||||
SupportedSignAlgs: []string{"ABC", "DEF"},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := NewIDTokenVerifier(tt.args.issuer, tt.args.clientID, tt.args.keySet, tt.args.options...)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,86 +0,0 @@
|
|||
package rp_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
tu "git.christmann.info/LARA/zitadel-oidc/v3/internal/testutil"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/client/rp"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
)
|
||||
|
||||
// MyCustomClaims extends the TokenClaims base,
|
||||
// so it implmeents the oidc.Claims interface.
|
||||
// Instead of carrying a map, we add needed fields// to the struct for type safe access.
|
||||
type MyCustomClaims struct {
|
||||
oidc.TokenClaims
|
||||
NotBefore oidc.Time `json:"nbf,omitempty"`
|
||||
AccessTokenHash string `json:"at_hash,omitempty"`
|
||||
Foo string `json:"foo,omitempty"`
|
||||
Bar *Nested `json:"bar,omitempty"`
|
||||
}
|
||||
|
||||
// GetAccessTokenHash is required to implement
|
||||
// the oidc.IDClaims interface.
|
||||
func (c *MyCustomClaims) GetAccessTokenHash() string {
|
||||
return c.AccessTokenHash
|
||||
}
|
||||
|
||||
// Nested struct types are also possible.
|
||||
type Nested struct {
|
||||
Count int `json:"count,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
/*
|
||||
idToken carries the following claims. foo and bar are custom claims
|
||||
|
||||
{
|
||||
"acr": "something",
|
||||
"amr": [
|
||||
"foo",
|
||||
"bar"
|
||||
],
|
||||
"at_hash": "2dzbm_vIxy-7eRtqUIGPPw",
|
||||
"aud": [
|
||||
"unit",
|
||||
"test",
|
||||
"555666"
|
||||
],
|
||||
"auth_time": 1678100961,
|
||||
"azp": "555666",
|
||||
"bar": {
|
||||
"count": 22,
|
||||
"tags": [
|
||||
"some",
|
||||
"tags"
|
||||
]
|
||||
},
|
||||
"client_id": "555666",
|
||||
"exp": 4802238682,
|
||||
"foo": "Hello, World!",
|
||||
"iat": 1678101021,
|
||||
"iss": "local.com",
|
||||
"jti": "9876",
|
||||
"nbf": 1678101021,
|
||||
"nonce": "12345",
|
||||
"sub": "tim@local.com"
|
||||
}
|
||||
*/
|
||||
const idToken = `eyJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJhY3IiOiJzb21ldGhpbmciLCJhbXIiOlsiZm9vIiwiYmFyIl0sImF0X2hhc2giOiIyZHpibV92SXh5LTdlUnRxVUlHUFB3IiwiYXVkIjpbInVuaXQiLCJ0ZXN0IiwiNTU1NjY2Il0sImF1dGhfdGltZSI6MTY3ODEwMDk2MSwiYXpwIjoiNTU1NjY2IiwiYmFyIjp7ImNvdW50IjoyMiwidGFncyI6WyJzb21lIiwidGFncyJdfSwiY2xpZW50X2lkIjoiNTU1NjY2IiwiZXhwIjo0ODAyMjM4NjgyLCJmb28iOiJIZWxsbywgV29ybGQhIiwiaWF0IjoxNjc4MTAxMDIxLCJpc3MiOiJsb2NhbC5jb20iLCJqdGkiOiI5ODc2IiwibmJmIjoxNjc4MTAxMDIxLCJub25jZSI6IjEyMzQ1Iiwic3ViIjoidGltQGxvY2FsLmNvbSJ9.t3GXSfVNNwiW1Suv9_84v0sdn2_-RWHVxhphhRozDXnsO7SDNOlGnEioemXABESxSzMclM7gB7mYy5Qah2ZUNx7eP5t2njoxEYfavgHwx7UJZ2NCg8NDPQyr-hlxelEcfdXK-I0oTd-FRDvF4rqPkD9Us52IpnplChCxnHFgh4wKwPqZZjv2IXVCtn0ilKW3hff1rMOYKEuLRcN2YP0gkyuqyHvcf2dMmjod0t4sLOTJ82rsCbMBC5CLpqv3nIC9HOGITkt1Kd-Am0n1LrdZvWwTo6RFe8AnzF0gpqjcB5Wg4Qeh58DIjZOz4f_8wnmJ_gCqyRh5vfSW4XHdbum0Tw`
|
||||
const accessToken = `eyJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJhdWQiOlsidW5pdCIsInRlc3QiXSwiYmFyIjp7ImNvdW50IjoyMiwidGFncyI6WyJzb21lIiwidGFncyJdfSwiZXhwIjo0ODAyMjM4NjgyLCJmb28iOiJIZWxsbywgV29ybGQhIiwiaWF0IjoxNjc4MTAxMDIxLCJpc3MiOiJsb2NhbC5jb20iLCJqdGkiOiI5ODc2IiwibmJmIjoxNjc4MTAxMDIxLCJzdWIiOiJ0aW1AbG9jYWwuY29tIn0.Zrz3LWSRjCMJZUMaI5dUbW4vGdSmEeJQ3ouhaX0bcW9rdFFLgBI4K2FWJhNivq8JDmCGSxwLu3mI680GWmDaEoAx1M5sCO9lqfIZHGZh-lfAXk27e6FPLlkTDBq8Bx4o4DJ9Fw0hRJGjUTjnYv5cq1vo2-UqldasL6CwTbkzNC_4oQFfRtuodC4Ql7dZ1HRv5LXuYx7KPkOssLZtV9cwtJp5nFzKjcf2zEE_tlbjcpynMwypornRUp1EhCWKRUGkJhJeiP71ECY5pQhShfjBu9Nc5wDpSnZmnk2S4YsPrRK3QkE-iEkas8BfsOCrGoErHjEJexAIDjasGO5PFLWfCA`
|
||||
|
||||
func ExampleVerifyTokens_customClaims() {
|
||||
v := rp.NewIDTokenVerifier("local.com", "555666", tu.KeySet{},
|
||||
rp.WithNonce(func(ctx context.Context) string { return "12345" }),
|
||||
)
|
||||
|
||||
// VerifyAccessToken can be called with the *MyCustomClaims.
|
||||
claims, err := rp.VerifyTokens[*MyCustomClaims](context.TODO(), accessToken, idToken, v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// Here we have typesafe access to the custom claims
|
||||
fmt.Println(claims.Foo, claims.Bar.Count, claims.Bar.Tags)
|
||||
// Output: Hello, World! 22 [some tags]
|
||||
}
|
|
@ -1,52 +0,0 @@
|
|||
package rs_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/client/rs"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
)
|
||||
|
||||
type IntrospectionResponse struct {
|
||||
Active bool `json:"active"`
|
||||
Scope oidc.SpaceDelimitedArray `json:"scope,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
TokenType string `json:"token_type,omitempty"`
|
||||
Expiration oidc.Time `json:"exp,omitempty"`
|
||||
IssuedAt oidc.Time `json:"iat,omitempty"`
|
||||
NotBefore oidc.Time `json:"nbf,omitempty"`
|
||||
Subject string `json:"sub,omitempty"`
|
||||
Audience oidc.Audience `json:"aud,omitempty"`
|
||||
Issuer string `json:"iss,omitempty"`
|
||||
JWTID string `json:"jti,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
oidc.UserInfoProfile
|
||||
oidc.UserInfoEmail
|
||||
oidc.UserInfoPhone
|
||||
Address *oidc.UserInfoAddress `json:"address,omitempty"`
|
||||
|
||||
// Foo and Bar are custom claims
|
||||
Foo string `json:"foo,omitempty"`
|
||||
Bar struct {
|
||||
Val1 string `json:"val_1,omitempty"`
|
||||
Val2 string `json:"val_2,omitempty"`
|
||||
} `json:"bar,omitempty"`
|
||||
|
||||
// Claims are all the combined claims, including custom.
|
||||
Claims map[string]any `json:"-,omitempty"`
|
||||
}
|
||||
|
||||
func ExampleIntrospect_custom() {
|
||||
rss, err := rs.NewResourceServerClientCredentials(context.TODO(), "http://localhost:8080", "clientid", "clientsecret")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
resp, err := rs.Introspect[*IntrospectionResponse](context.TODO(), rss, "accesstokenstring")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Println(resp)
|
||||
}
|
|
@ -6,16 +6,15 @@ import (
|
|||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/client"
|
||||
httphelper "git.christmann.info/LARA/zitadel-oidc/v3/pkg/http"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
"github.com/zitadel/oidc/pkg/client"
|
||||
httphelper "github.com/zitadel/oidc/pkg/http"
|
||||
"github.com/zitadel/oidc/pkg/oidc"
|
||||
)
|
||||
|
||||
type ResourceServer interface {
|
||||
IntrospectionURL() string
|
||||
TokenEndpoint() string
|
||||
HttpClient() *http.Client
|
||||
AuthFn() (any, error)
|
||||
AuthFn() (interface{}, error)
|
||||
}
|
||||
|
||||
type resourceServer struct {
|
||||
|
@ -23,48 +22,44 @@ type resourceServer struct {
|
|||
tokenURL string
|
||||
introspectURL string
|
||||
httpClient *http.Client
|
||||
authFn func() (any, error)
|
||||
authFn func() (interface{}, error)
|
||||
}
|
||||
|
||||
func (r *resourceServer) IntrospectionURL() string {
|
||||
return r.introspectURL
|
||||
}
|
||||
|
||||
func (r *resourceServer) TokenEndpoint() string {
|
||||
return r.tokenURL
|
||||
}
|
||||
|
||||
func (r *resourceServer) HttpClient() *http.Client {
|
||||
return r.httpClient
|
||||
}
|
||||
|
||||
func (r *resourceServer) AuthFn() (any, error) {
|
||||
func (r *resourceServer) AuthFn() (interface{}, error) {
|
||||
return r.authFn()
|
||||
}
|
||||
|
||||
func NewResourceServerClientCredentials(ctx context.Context, issuer, clientID, clientSecret string, option ...Option) (ResourceServer, error) {
|
||||
authorizer := func() (any, error) {
|
||||
func NewResourceServerClientCredentials(issuer, clientID, clientSecret string, option ...Option) (ResourceServer, error) {
|
||||
authorizer := func() (interface{}, error) {
|
||||
return httphelper.AuthorizeBasic(clientID, clientSecret), nil
|
||||
}
|
||||
return newResourceServer(ctx, issuer, authorizer, option...)
|
||||
return newResourceServer(issuer, authorizer, option...)
|
||||
}
|
||||
|
||||
func NewResourceServerJWTProfile(ctx context.Context, issuer, clientID, keyID string, key []byte, options ...Option) (ResourceServer, error) {
|
||||
func NewResourceServerJWTProfile(issuer, clientID, keyID string, key []byte, options ...Option) (ResourceServer, error) {
|
||||
signer, err := client.NewSignerFromPrivateKeyByte(key, keyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authorizer := func() (any, error) {
|
||||
authorizer := func() (interface{}, error) {
|
||||
assertion, err := client.SignedJWTProfileAssertion(clientID, []string{issuer}, time.Hour, signer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.ClientAssertionFormAuthorization(assertion), nil
|
||||
}
|
||||
return newResourceServer(ctx, issuer, authorizer, options...)
|
||||
return newResourceServer(issuer, authorizer, options...)
|
||||
}
|
||||
|
||||
func newResourceServer(ctx context.Context, issuer string, authorizer func() (any, error), options ...Option) (*resourceServer, error) {
|
||||
func newResourceServer(issuer string, authorizer func() (interface{}, error), options ...Option) (*resourceServer, error) {
|
||||
rs := &resourceServer{
|
||||
issuer: issuer,
|
||||
httpClient: httphelper.DefaultHTTPClient,
|
||||
|
@ -73,30 +68,26 @@ func newResourceServer(ctx context.Context, issuer string, authorizer func() (an
|
|||
optFunc(rs)
|
||||
}
|
||||
if rs.introspectURL == "" || rs.tokenURL == "" {
|
||||
config, err := client.Discover(ctx, rs.issuer, rs.httpClient)
|
||||
config, err := client.Discover(rs.issuer, rs.httpClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rs.tokenURL == "" {
|
||||
rs.tokenURL = config.TokenEndpoint
|
||||
}
|
||||
if rs.introspectURL == "" {
|
||||
rs.introspectURL = config.IntrospectionEndpoint
|
||||
}
|
||||
}
|
||||
if rs.tokenURL == "" {
|
||||
return nil, errors.New("tokenURL is empty: please provide with either `WithStaticEndpoints` or a discovery url")
|
||||
if rs.introspectURL == "" || rs.tokenURL == "" {
|
||||
return nil, errors.New("introspectURL and/or tokenURL is empty: please provide with either `WithStaticEndpoints` or a discovery url")
|
||||
}
|
||||
rs.authFn = authorizer
|
||||
return rs, nil
|
||||
}
|
||||
|
||||
func NewResourceServerFromKeyFile(ctx context.Context, issuer, path string, options ...Option) (ResourceServer, error) {
|
||||
func NewResourceServerFromKeyFile(issuer, path string, options ...Option) (ResourceServer, error) {
|
||||
c, err := client.ConfigFromKeyFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewResourceServerJWTProfile(ctx, issuer, c.ClientID, c.KeyID, []byte(c.Key), options...)
|
||||
return NewResourceServerJWTProfile(issuer, c.ClientID, c.KeyID, []byte(c.Key), options...)
|
||||
}
|
||||
|
||||
type Option func(*resourceServer)
|
||||
|
@ -116,30 +107,18 @@ func WithStaticEndpoints(tokenURL, introspectURL string) Option {
|
|||
}
|
||||
}
|
||||
|
||||
// Introspect calls the [RFC7662] Token Introspection
|
||||
// endpoint and returns the response in an instance of type R.
|
||||
// [*oidc.IntrospectionResponse] can be used as a good example, or use a custom type if type-safe
|
||||
// access to custom claims is needed.
|
||||
//
|
||||
// [RFC7662]: https://www.rfc-editor.org/rfc/rfc7662
|
||||
func Introspect[R any](ctx context.Context, rp ResourceServer, token string) (resp R, err error) {
|
||||
ctx, span := client.Tracer.Start(ctx, "Introspect")
|
||||
defer span.End()
|
||||
|
||||
if rp.IntrospectionURL() == "" {
|
||||
return resp, errors.New("resource server: introspection URL is empty")
|
||||
}
|
||||
func Introspect(ctx context.Context, rp ResourceServer, token string) (oidc.IntrospectionResponse, error) {
|
||||
authFn, err := rp.AuthFn()
|
||||
if err != nil {
|
||||
return resp, err
|
||||
return nil, err
|
||||
}
|
||||
req, err := httphelper.FormRequest(ctx, rp.IntrospectionURL(), &oidc.IntrospectionRequest{Token: token}, client.Encoder, authFn)
|
||||
req, err := httphelper.FormRequest(rp.IntrospectionURL(), &oidc.IntrospectionRequest{Token: token}, client.Encoder, authFn)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := httphelper.HttpRequest(rp.HttpClient(), req, &resp); err != nil {
|
||||
return resp, err
|
||||
resp := oidc.NewIntrospectionResponse()
|
||||
if err := httphelper.HttpRequest(rp.HttpClient(), req, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
|
|
@ -1,221 +0,0 @@
|
|||
package rs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewResourceServer(t *testing.T) {
|
||||
type args struct {
|
||||
issuer string
|
||||
authorizer func() (any, error)
|
||||
options []Option
|
||||
}
|
||||
type wantFields struct {
|
||||
issuer string
|
||||
tokenURL string
|
||||
introspectURL string
|
||||
authFn func() (any, error)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantFields *wantFields
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "spotify-full-discovery",
|
||||
args: args{
|
||||
issuer: "https://accounts.spotify.com",
|
||||
authorizer: nil,
|
||||
options: []Option{},
|
||||
},
|
||||
wantFields: &wantFields{
|
||||
issuer: "https://accounts.spotify.com",
|
||||
tokenURL: "https://accounts.spotify.com/api/token",
|
||||
introspectURL: "",
|
||||
authFn: nil,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "spotify-with-static-tokenurl",
|
||||
args: args{
|
||||
issuer: "https://accounts.spotify.com",
|
||||
authorizer: nil,
|
||||
options: []Option{
|
||||
WithStaticEndpoints(
|
||||
"https://some.host/token-url",
|
||||
"",
|
||||
),
|
||||
},
|
||||
},
|
||||
wantFields: &wantFields{
|
||||
issuer: "https://accounts.spotify.com",
|
||||
tokenURL: "https://some.host/token-url",
|
||||
introspectURL: "",
|
||||
authFn: nil,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "spotify-with-static-introspecturl",
|
||||
args: args{
|
||||
issuer: "https://accounts.spotify.com",
|
||||
authorizer: nil,
|
||||
options: []Option{
|
||||
WithStaticEndpoints(
|
||||
"",
|
||||
"https://some.host/instrospect-url",
|
||||
),
|
||||
},
|
||||
},
|
||||
wantFields: &wantFields{
|
||||
issuer: "https://accounts.spotify.com",
|
||||
tokenURL: "https://accounts.spotify.com/api/token",
|
||||
introspectURL: "https://some.host/instrospect-url",
|
||||
authFn: nil,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "spotify-with-all-static-endpoints",
|
||||
args: args{
|
||||
issuer: "https://accounts.spotify.com",
|
||||
authorizer: nil,
|
||||
options: []Option{
|
||||
WithStaticEndpoints(
|
||||
"https://some.host/token-url",
|
||||
"https://some.host/instrospect-url",
|
||||
),
|
||||
},
|
||||
},
|
||||
wantFields: &wantFields{
|
||||
issuer: "https://accounts.spotify.com",
|
||||
tokenURL: "https://some.host/token-url",
|
||||
introspectURL: "https://some.host/instrospect-url",
|
||||
authFn: nil,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "bad-discovery",
|
||||
args: args{
|
||||
issuer: "https://127.0.0.1:65535",
|
||||
authorizer: nil,
|
||||
options: []Option{},
|
||||
},
|
||||
wantFields: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "bad-discovery-with-static-tokenurl",
|
||||
args: args{
|
||||
issuer: "https://127.0.0.1:65535",
|
||||
authorizer: nil,
|
||||
options: []Option{
|
||||
WithStaticEndpoints(
|
||||
"https://some.host/token-url",
|
||||
"",
|
||||
),
|
||||
},
|
||||
},
|
||||
wantFields: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "bad-discovery-with-static-introspecturl",
|
||||
args: args{
|
||||
issuer: "https://127.0.0.1:65535",
|
||||
authorizer: nil,
|
||||
options: []Option{
|
||||
WithStaticEndpoints(
|
||||
"",
|
||||
"https://some.host/instrospect-url",
|
||||
),
|
||||
},
|
||||
},
|
||||
wantFields: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "bad-discovery-with-all-static-endpoints",
|
||||
args: args{
|
||||
issuer: "https://127.0.0.1:65535",
|
||||
authorizer: nil,
|
||||
options: []Option{
|
||||
WithStaticEndpoints(
|
||||
"https://some.host/token-url",
|
||||
"https://some.host/instrospect-url",
|
||||
),
|
||||
},
|
||||
},
|
||||
wantFields: &wantFields{
|
||||
issuer: "https://127.0.0.1:65535",
|
||||
tokenURL: "https://some.host/token-url",
|
||||
introspectURL: "https://some.host/instrospect-url",
|
||||
authFn: nil,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := newResourceServer(context.Background(), tt.args.issuer, tt.args.authorizer, tt.args.options...)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
if tt.wantFields == nil {
|
||||
return
|
||||
}
|
||||
assert.Equal(t, tt.wantFields.issuer, got.issuer)
|
||||
assert.Equal(t, tt.wantFields.tokenURL, got.tokenURL)
|
||||
assert.Equal(t, tt.wantFields.introspectURL, got.introspectURL)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntrospect(t *testing.T) {
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
rp ResourceServer
|
||||
token string
|
||||
}
|
||||
rp, err := newResourceServer(
|
||||
context.Background(),
|
||||
"https://accounts.spotify.com",
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "missing-introspect-url",
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
rp: rp,
|
||||
token: "my-token",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := Introspect[*oidc.IntrospectionResponse](tt.args.ctx, tt.args.rp, tt.args.token)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,145 +0,0 @@
|
|||
package tokenexchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/client"
|
||||
httphelper "git.christmann.info/LARA/zitadel-oidc/v3/pkg/http"
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
)
|
||||
|
||||
type TokenExchanger interface {
|
||||
TokenEndpoint() string
|
||||
HttpClient() *http.Client
|
||||
AuthFn() (any, error)
|
||||
}
|
||||
|
||||
type OAuthTokenExchange struct {
|
||||
httpClient *http.Client
|
||||
tokenEndpoint string
|
||||
authFn func() (any, error)
|
||||
}
|
||||
|
||||
func NewTokenExchanger(ctx context.Context, issuer string, options ...func(source *OAuthTokenExchange)) (TokenExchanger, error) {
|
||||
return newOAuthTokenExchange(ctx, issuer, nil, options...)
|
||||
}
|
||||
|
||||
func NewTokenExchangerClientCredentials(ctx context.Context, issuer, clientID, clientSecret string, options ...func(source *OAuthTokenExchange)) (TokenExchanger, error) {
|
||||
authorizer := func() (any, error) {
|
||||
return httphelper.AuthorizeBasic(clientID, clientSecret), nil
|
||||
}
|
||||
return newOAuthTokenExchange(ctx, issuer, authorizer, options...)
|
||||
}
|
||||
|
||||
func NewTokenExchangerJWTProfile(ctx context.Context, issuer, clientID string, signer jose.Signer, options ...func(source *OAuthTokenExchange)) (TokenExchanger, error) {
|
||||
authorizer := func() (any, error) {
|
||||
assertion, err := client.SignedJWTProfileAssertion(clientID, []string{issuer}, time.Hour, signer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.ClientAssertionFormAuthorization(assertion), nil
|
||||
}
|
||||
return newOAuthTokenExchange(ctx, issuer, authorizer, options...)
|
||||
}
|
||||
|
||||
func newOAuthTokenExchange(ctx context.Context, issuer string, authorizer func() (any, error), options ...func(source *OAuthTokenExchange)) (*OAuthTokenExchange, error) {
|
||||
te := &OAuthTokenExchange{
|
||||
httpClient: httphelper.DefaultHTTPClient,
|
||||
}
|
||||
for _, opt := range options {
|
||||
opt(te)
|
||||
}
|
||||
|
||||
if te.tokenEndpoint == "" {
|
||||
config, err := client.Discover(ctx, issuer, te.httpClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
te.tokenEndpoint = config.TokenEndpoint
|
||||
}
|
||||
|
||||
if te.tokenEndpoint == "" {
|
||||
return nil, errors.New("tokenURL is empty: please provide with either `WithStaticTokenEndpoint` or a discovery url")
|
||||
}
|
||||
|
||||
te.authFn = authorizer
|
||||
|
||||
return te, nil
|
||||
}
|
||||
|
||||
func WithHTTPClient(client *http.Client) func(*OAuthTokenExchange) {
|
||||
return func(source *OAuthTokenExchange) {
|
||||
source.httpClient = client
|
||||
}
|
||||
}
|
||||
|
||||
func WithStaticTokenEndpoint(issuer, tokenEndpoint string) func(*OAuthTokenExchange) {
|
||||
return func(source *OAuthTokenExchange) {
|
||||
source.tokenEndpoint = tokenEndpoint
|
||||
}
|
||||
}
|
||||
|
||||
func (te *OAuthTokenExchange) TokenEndpoint() string {
|
||||
return te.tokenEndpoint
|
||||
}
|
||||
|
||||
func (te *OAuthTokenExchange) HttpClient() *http.Client {
|
||||
return te.httpClient
|
||||
}
|
||||
|
||||
func (te *OAuthTokenExchange) AuthFn() (any, error) {
|
||||
if te.authFn != nil {
|
||||
return te.authFn()
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ExchangeToken sends a token exchange request (rfc 8693) to te's token endpoint.
|
||||
// SubjectToken and SubjectTokenType are required parameters.
|
||||
func ExchangeToken(
|
||||
ctx context.Context,
|
||||
te TokenExchanger,
|
||||
SubjectToken string,
|
||||
SubjectTokenType oidc.TokenType,
|
||||
ActorToken string,
|
||||
ActorTokenType oidc.TokenType,
|
||||
Resource []string,
|
||||
Audience []string,
|
||||
Scopes []string,
|
||||
RequestedTokenType oidc.TokenType,
|
||||
) (*oidc.TokenExchangeResponse, error) {
|
||||
ctx, span := client.Tracer.Start(ctx, "ExchangeToken")
|
||||
defer span.End()
|
||||
|
||||
if SubjectToken == "" {
|
||||
return nil, errors.New("empty subject_token")
|
||||
}
|
||||
if SubjectTokenType == "" {
|
||||
return nil, errors.New("empty subject_token_type")
|
||||
}
|
||||
|
||||
authFn, err := te.AuthFn()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request := oidc.TokenExchangeRequest{
|
||||
GrantType: oidc.GrantTypeTokenExchange,
|
||||
SubjectToken: SubjectToken,
|
||||
SubjectTokenType: SubjectTokenType,
|
||||
ActorToken: ActorToken,
|
||||
ActorTokenType: ActorTokenType,
|
||||
Resource: Resource,
|
||||
Audience: Audience,
|
||||
Scopes: Scopes,
|
||||
RequestedTokenType: RequestedTokenType,
|
||||
}
|
||||
|
||||
return client.CallTokenExchangeEndpoint(ctx, request, authFn, te)
|
||||
}
|
|
@ -8,7 +8,7 @@ import (
|
|||
"fmt"
|
||||
"hash"
|
||||
|
||||
jose "github.com/go-jose/go-jose/v4"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
)
|
||||
|
||||
var ErrUnsupportedAlgorithm = errors.New("unsupported signing algorithm")
|
||||
|
@ -21,14 +21,6 @@ func GetHashAlgorithm(sigAlgorithm jose.SignatureAlgorithm) (hash.Hash, error) {
|
|||
return sha512.New384(), nil
|
||||
case jose.RS512, jose.ES512, jose.PS512:
|
||||
return sha512.New(), nil
|
||||
|
||||
// There is no published spec for this yet, but we have confirmation it will get published.
|
||||
// There is consensus here: https://bitbucket.org/openid/connect/issues/1125/_hash-algorithm-for-eddsa-id-tokens
|
||||
// Currently Go and go-jose only supports the ed25519 curve key for EdDSA, so we can safely assume sha512 here.
|
||||
// It is unlikely ed448 will ever be supported: https://github.com/golang/go/issues/29390
|
||||
case jose.EdDSA:
|
||||
return sha512.New(), nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %q", ErrUnsupportedAlgorithm, sigAlgorithm)
|
||||
}
|
||||
|
|
|
@ -1,45 +1,17 @@
|
|||
package crypto
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPEMDecode = errors.New("PEM decode failed")
|
||||
ErrUnsupportedFormat = errors.New("key is neither in PKCS#1 nor PKCS#8 format")
|
||||
ErrUnsupportedPrivateKey = errors.New("unsupported key type, must be RSA, ECDSA or ED25519 private key")
|
||||
)
|
||||
|
||||
func BytesToPrivateKey(b []byte) (crypto.PublicKey, jose.SignatureAlgorithm, error) {
|
||||
block, _ := pem.Decode(b)
|
||||
if block == nil {
|
||||
return nil, "", ErrPEMDecode
|
||||
}
|
||||
|
||||
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err == nil {
|
||||
return privateKey, jose.RS256, nil
|
||||
}
|
||||
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
func BytesToPrivateKey(priv []byte) (*rsa.PrivateKey, error) {
|
||||
block, _ := pem.Decode(priv)
|
||||
b := block.Bytes
|
||||
key, err := x509.ParsePKCS1PrivateKey(b)
|
||||
if err != nil {
|
||||
return nil, "", ErrUnsupportedFormat
|
||||
}
|
||||
switch privateKey := key.(type) {
|
||||
case *rsa.PrivateKey:
|
||||
return privateKey, jose.RS256, nil
|
||||
case ed25519.PrivateKey:
|
||||
return privateKey, jose.EdDSA, nil
|
||||
case *ecdsa.PrivateKey:
|
||||
return privateKey, jose.ES256, nil
|
||||
default:
|
||||
return nil, "", ErrUnsupportedPrivateKey
|
||||
return nil, err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
|
|
@ -1,134 +0,0 @@
|
|||
package crypto_test
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/rsa"
|
||||
"testing"
|
||||
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
zcrypto "git.christmann.info/LARA/zitadel-oidc/v3/pkg/crypto"
|
||||
)
|
||||
|
||||
func TestBytesToPrivateKey(t *testing.T) {
|
||||
type args struct {
|
||||
key []byte
|
||||
}
|
||||
type want struct {
|
||||
key crypto.Signer
|
||||
algorithm jose.SignatureAlgorithm
|
||||
err error
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want want
|
||||
}{
|
||||
{
|
||||
name: "PEMDecodeError",
|
||||
args: args{
|
||||
key: []byte("The non-PEM sequence"),
|
||||
},
|
||||
want: want{
|
||||
err: zcrypto.ErrPEMDecode,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "PKCS#1 RSA",
|
||||
args: args{
|
||||
key: []byte(`-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIBOgIBAAJBAKj34GkxFhD90vcNLYLInFEX6Ppy1tPf9Cnzj4p4WGeKLs1Pt8Qu
|
||||
KUpRKfFLfRYC9AIKjbJTWit+CqvjWYzvQwECAwEAAQJAIJLixBy2qpFoS4DSmoEm
|
||||
o3qGy0t6z09AIJtH+5OeRV1be+N4cDYJKffGzDa88vQENZiRm0GRq6a+HPGQMd2k
|
||||
TQIhAKMSvzIBnni7ot/OSie2TmJLY4SwTQAevXysE2RbFDYdAiEBCUEaRQnMnbp7
|
||||
9mxDXDf6AU0cN/RPBjb9qSHDcWZHGzUCIG2Es59z8ugGrDY+pxLQnwfotadxd+Uy
|
||||
v/Ow5T0q5gIJAiEAyS4RaI9YG8EWx/2w0T67ZUVAw8eOMB6BIUg0Xcu+3okCIBOs
|
||||
/5OiPgoTdSy7bcF9IGpSE8ZgGKzgYQVZeN97YE00
|
||||
-----END RSA PRIVATE KEY-----`),
|
||||
},
|
||||
want: want{
|
||||
key: &rsa.PrivateKey{},
|
||||
algorithm: jose.RS256,
|
||||
err: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "PKCS#8 RSA",
|
||||
args: args{
|
||||
key: []byte(`-----BEGIN PRIVATE KEY-----
|
||||
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCfaDB7pK/fmP/I
|
||||
7IusSK8lTCBnPZghqIbVLt2QHYAMoEF1CaF4F4rxo2vl1Mt8gwsq4T3osQFZMvnL
|
||||
YHb7KNyUoJgTjLxJQADv2u4Q3U38heAzK5Tp4ry4MCnuyJIqAPK1GiruwEq4zQrx
|
||||
+WzVix8otO37SuW9tzklqlNGMiAYBL0TBKHvS5XMbjP1idBMB8erMz29w/TVQnEB
|
||||
Kj0vCdZjrbVPKygptt5kcSrL5f4xCZwU+ufz7cp0GLwpRMJ+shG9YJJFBxb0itPF
|
||||
sy51vAyEtdBC7jgAU96ZVeQ06nryDq1D2EpoVMElqNyL46Jo3lnKbGquGKzXzQYU
|
||||
BN32/scDAgMBAAECggEBAJE/mo3PLgILo2YtQ8ekIxNVHmF0Gl7w9IrjvTdH6hmX
|
||||
HI3MTLjkmtI7GmG9V/0IWvCjdInGX3grnrjWGRQZ04QKIQgPQLFuBGyJjEsJm7nx
|
||||
MqztlS7YTyV1nX/aenSTkJO8WEpcJLnm+4YoxCaAMdAhrIdBY71OamALpv1bRysa
|
||||
FaiCGcemT2yqZn0GqIS8O26Tz5zIqrTN2G1eSmgh7DG+7FoddMz35cute8R10xUG
|
||||
hF5YU+6fcXiRQ/Kh7nlxelPGqdZFPMk7LpVHzkQKwdJ+N0P23lPDIfNsvpG1n0OP
|
||||
3g5km7gHSrSU2yZ3eFl6DB9x1IFNS9BaQQuSxYJtKwECgYEA1C8jjzpXZDLvlYsV
|
||||
2jlMzkrbsIrX2dzblVrNsPs2jRbjYU8mg2DUDO6lOhtxHfqZG6sO+gmWi/zvoy9l
|
||||
yolGbXe1Jqx66p9fznIcecSwar8+ACa356Wk74Nt1PlBOfCMqaJnYLOLaFJa29Vy
|
||||
u5ClZVzKd5AVXl7yFVd4XfLv/WECgYEAwFMMtFoasdF92c0d31rZ1uoPOtFz6xq6
|
||||
uQggdm5zzkhnfwUAGqppS/u1CHcJ7T/74++jLbFTsaohGr4jEzWSGvJpomEUChy3
|
||||
r25YofMclUhJ5pCEStsLtqiCR1Am6LlI8HMdBEP1QDgEC5q8bQW4+UHuew1E1zxz
|
||||
osZOhe09WuMCgYEA0G9aFCnwjUqIFjQiDFP7gi8BLqTFs4uE3Wvs4W11whV42i+B
|
||||
ms90nxuTjchFT3jMDOT1+mOO0wdudLRr3xEI8SIF/u6ydGaJG+j21huEXehtxIJE
|
||||
aDdNFcfbDbqo+3y1ATK7MMBPMvSrsoY0hdJq127WqasNgr3sO1DIuima3SECgYEA
|
||||
nkM5TyhekzlbIOHD1UsDu/D7+2DkzPE/+oePfyXBMl0unb3VqhvVbmuBO6gJiSx/
|
||||
8b//PdiQkMD5YPJaFrKcuoQFHVRZk0CyfzCEyzAts0K7XXpLAvZiGztriZeRjSz7
|
||||
srJnjF0H8oKmAY6hw+1Tm/n/b08p+RyL48TgVSE2vhUCgYA3BWpkD4PlCcn/FZsq
|
||||
OrLFyFXI6jIaxskFtsRW1IxxIlAdZmxfB26P/2gx6VjLdxJI/RRPkJyEN2dP7CbR
|
||||
BDjb565dy1O9D6+UrY70Iuwjz+OcALRBBGTaiF2pLn6IhSzNI2sy/tXX8q8dBlg9
|
||||
OFCrqT/emes3KytTPfa5NZtYeQ==
|
||||
-----END PRIVATE KEY-----`),
|
||||
},
|
||||
want: want{
|
||||
key: &rsa.PrivateKey{},
|
||||
algorithm: jose.RS256,
|
||||
err: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "PKCS#8 ECDSA",
|
||||
args: args{
|
||||
key: []byte(`-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgwwOZSU4GlP7ps/Wp
|
||||
V6o0qRwxultdfYo/uUuj48QZjSuhRANCAATMiI2Han+ABKmrk5CNlxRAGC61w4d3
|
||||
G4TAeuBpyzqJ7x/6NjCxoQzJzZHtNjIfjVATI59XFZWF59GhtSZbShAr
|
||||
-----END PRIVATE KEY-----`),
|
||||
},
|
||||
want: want{
|
||||
key: &ecdsa.PrivateKey{},
|
||||
algorithm: jose.ES256,
|
||||
err: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "PKCS#8 ED25519",
|
||||
args: args{
|
||||
key: []byte(`-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEIHu6ZtDsjjauMasBxnS9Fg87UJwKfcT/oiq6S0ktbky8
|
||||
-----END PRIVATE KEY-----`),
|
||||
},
|
||||
want: want{
|
||||
key: ed25519.PrivateKey{},
|
||||
algorithm: jose.EdDSA,
|
||||
err: nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
key, algorithm, err := zcrypto.BytesToPrivateKey(tt.args.key)
|
||||
assert.IsType(t, tt.want.key, key)
|
||||
assert.Equal(t, tt.want.algorithm, algorithm)
|
||||
assert.ErrorIs(t, tt.want.err, err)
|
||||
})
|
||||
|
||||
}
|
||||
}
|
|
@ -4,10 +4,10 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
jose "github.com/go-jose/go-jose/v4"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
)
|
||||
|
||||
func Sign(object any, signer jose.Signer) (string, error) {
|
||||
func Sign(object interface{}, signer jose.Signer) (string, error) {
|
||||
payload, err := json.Marshal(object)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
|
|
@ -10,8 +10,6 @@ import (
|
|||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/oidc"
|
||||
)
|
||||
|
||||
var DefaultHTTPClient = &http.Client{
|
||||
|
@ -19,11 +17,11 @@ var DefaultHTTPClient = &http.Client{
|
|||
}
|
||||
|
||||
type Decoder interface {
|
||||
Decode(dst any, src map[string][]string) error
|
||||
Decode(dst interface{}, src map[string][]string) error
|
||||
}
|
||||
|
||||
type Encoder interface {
|
||||
Encode(src any, dst map[string][]string) error
|
||||
Encode(src interface{}, dst map[string][]string) error
|
||||
}
|
||||
|
||||
type FormAuthorization func(url.Values)
|
||||
|
@ -35,7 +33,7 @@ func AuthorizeBasic(user, password string) RequestAuthorization {
|
|||
}
|
||||
}
|
||||
|
||||
func FormRequest(ctx context.Context, endpoint string, request any, encoder Encoder, authFn any) (*http.Request, error) {
|
||||
func FormRequest(endpoint string, request interface{}, encoder Encoder, authFn interface{}) (*http.Request, error) {
|
||||
form := url.Values{}
|
||||
if err := encoder.Encode(request, form); err != nil {
|
||||
return nil, err
|
||||
|
@ -44,7 +42,7 @@ func FormRequest(ctx context.Context, endpoint string, request any, encoder Enco
|
|||
fn(form)
|
||||
}
|
||||
body := strings.NewReader(form.Encode())
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body)
|
||||
req, err := http.NewRequest("POST", endpoint, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -55,7 +53,7 @@ func FormRequest(ctx context.Context, endpoint string, request any, encoder Enco
|
|||
return req, nil
|
||||
}
|
||||
|
||||
func HttpRequest(client *http.Client, req *http.Request, response any) error {
|
||||
func HttpRequest(client *http.Client, req *http.Request, response interface{}) error {
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -68,13 +66,8 @@ func HttpRequest(client *http.Client, req *http.Request, response any) error {
|
|||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var oidcErr oidc.Error
|
||||
err = json.Unmarshal(body, &oidcErr)
|
||||
if err != nil || oidcErr.ErrorType == "" {
|
||||
return fmt.Errorf("http status not ok: %s %s", resp.Status, body)
|
||||
}
|
||||
return &oidcErr
|
||||
}
|
||||
|
||||
err = json.Unmarshal(body, response)
|
||||
if err != nil {
|
||||
|
@ -83,7 +76,7 @@ func HttpRequest(client *http.Client, req *http.Request, response any) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func URLEncodeParams(resp any, encoder Encoder) (url.Values, error) {
|
||||
func URLEncodeParams(resp interface{}, encoder Encoder) (url.Values, error) {
|
||||
values := make(map[string][]string)
|
||||
err := encoder.Encode(resp, values)
|
||||
if err != nil {
|
||||
|
|
|
@ -8,11 +8,11 @@ import (
|
|||
"reflect"
|
||||
)
|
||||
|
||||
func MarshalJSON(w http.ResponseWriter, i any) {
|
||||
func MarshalJSON(w http.ResponseWriter, i interface{}) {
|
||||
MarshalJSONWithStatus(w, i, http.StatusOK)
|
||||
}
|
||||
|
||||
func MarshalJSONWithStatus(w http.ResponseWriter, i any, status int) {
|
||||
func MarshalJSONWithStatus(w http.ResponseWriter, i interface{}, status int) {
|
||||
w.Header().Set("content-type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if i == nil || (reflect.ValueOf(i).Kind() == reflect.Ptr && reflect.ValueOf(i).IsNil()) {
|
||||
|
|
|
@ -94,7 +94,7 @@ func TestConcatenateJSON(t *testing.T) {
|
|||
|
||||
func TestMarshalJSONWithStatus(t *testing.T) {
|
||||
type args struct {
|
||||
i any
|
||||
i interface{}
|
||||
status int
|
||||
}
|
||||
type res struct {
|
||||
|
|
|
@ -1,9 +1,5 @@
|
|||
package oidc
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
const (
|
||||
// ScopeOpenID defines the scope `openid`
|
||||
// OpenID Connect requests MUST contain the `openid` scope value
|
||||
|
@ -48,7 +44,6 @@ const (
|
|||
|
||||
ResponseModeQuery ResponseMode = "query"
|
||||
ResponseModeFragment ResponseMode = "fragment"
|
||||
ResponseModeFormPost ResponseMode = "form_post"
|
||||
|
||||
// PromptNone (`none`) disallows the Authorization Server to display any authentication or consent user interface pages.
|
||||
// An error (login_required, interaction_required, ...) will be returned if the user is not already authenticated or consent is needed
|
||||
|
@ -82,7 +77,7 @@ type AuthRequest struct {
|
|||
UILocales Locales `json:"ui_locales" schema:"ui_locales"`
|
||||
IDTokenHint string `json:"id_token_hint" schema:"id_token_hint"`
|
||||
LoginHint string `json:"login_hint" schema:"login_hint"`
|
||||
ACRValues SpaceDelimitedArray `json:"acr_values" schema:"acr_values"`
|
||||
ACRValues []string `json:"acr_values" schema:"acr_values"`
|
||||
|
||||
CodeChallenge string `json:"code_challenge" schema:"code_challenge"`
|
||||
CodeChallengeMethod CodeChallengeMethod `json:"code_challenge_method" schema:"code_challenge_method"`
|
||||
|
@ -91,15 +86,6 @@ type AuthRequest struct {
|
|||
RequestParam string `schema:"request"`
|
||||
}
|
||||
|
||||
func (a *AuthRequest) LogValue() slog.Value {
|
||||
return slog.GroupValue(
|
||||
slog.Any("scopes", a.Scopes),
|
||||
slog.String("response_type", string(a.ResponseType)),
|
||||
slog.String("client_id", a.ClientID),
|
||||
slog.String("redirect_uri", a.RedirectURI),
|
||||
)
|
||||
}
|
||||
|
||||
// GetRedirectURI returns the redirect_uri value for the ErrAuthRequest interface
|
||||
func (a *AuthRequest) GetRedirectURI() string {
|
||||
return a.RedirectURI
|
||||
|
@ -114,8 +100,3 @@ func (a *AuthRequest) GetResponseType() ResponseType {
|
|||
func (a *AuthRequest) GetState() string {
|
||||
return a.State
|
||||
}
|
||||
|
||||
// GetResponseMode returns the optional ResponseMode
|
||||
func (a *AuthRequest) GetResponseMode() ResponseMode {
|
||||
return a.ResponseMode
|
||||
}
|
||||
|
|
|
@ -1,27 +0,0 @@
|
|||
//go:build go1.20
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAuthRequest_LogValue(t *testing.T) {
|
||||
a := &AuthRequest{
|
||||
Scopes: SpaceDelimitedArray{"a", "b"},
|
||||
ResponseType: "respType",
|
||||
ClientID: "123",
|
||||
RedirectURI: "http://example.com/callback",
|
||||
}
|
||||
want := slog.GroupValue(
|
||||
slog.Any("scopes", SpaceDelimitedArray{"a", "b"}),
|
||||
slog.String("response_type", "respType"),
|
||||
slog.String("client_id", "123"),
|
||||
slog.String("redirect_uri", "http://example.com/callback"),
|
||||
)
|
||||
got := a.LogValue()
|
||||
assert.Equal(t, want, got)
|
||||
}
|
|
@ -3,7 +3,7 @@ package oidc
|
|||
import (
|
||||
"crypto/sha256"
|
||||
|
||||
"git.christmann.info/LARA/zitadel-oidc/v3/pkg/crypto"
|
||||
"github.com/zitadel/oidc/pkg/crypto"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
|
@ -1,51 +0,0 @@
|
|||
package oidc
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// DeviceAuthorizationRequest implements
|
||||
// https://www.rfc-editor.org/rfc/rfc8628#section-3.1,
|
||||
// 3.1 Device Authorization Request.
|
||||
type DeviceAuthorizationRequest struct {
|
||||
Scopes SpaceDelimitedArray `schema:"scope"`
|
||||
ClientID string `schema:"client_id"`
|
||||
}
|
||||
|
||||
// DeviceAuthorizationResponse implements
|
||||
// https://www.rfc-editor.org/rfc/rfc8628#section-3.2
|
||||
// 3.2. Device Authorization Response.
|
||||
type DeviceAuthorizationResponse struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationURI string `json:"verification_uri"`
|
||||
VerificationURIComplete string `json:"verification_uri_complete,omitempty"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Interval int `json:"interval,omitempty"`
|
||||
}
|
||||
|
||||
func (resp *DeviceAuthorizationResponse) UnmarshalJSON(data []byte) error {
|
||||
type Alias DeviceAuthorizationResponse
|
||||
aux := &struct {
|
||||
// workaround misspelling of verification_uri
|
||||
// https://stackoverflow.com/q/76696956/5690223
|
||||
// https://developers.google.com/identity/protocols/oauth2/limited-input-device?hl=fr#success-response
|
||||
VerificationURL string `json:"verification_url"`
|
||||
*Alias
|
||||
}{
|
||||
Alias: (*Alias)(resp),
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.VerificationURI == "" {
|
||||
resp.VerificationURI = aux.VerificationURL
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeviceAccessTokenRequest implements
|
||||
// https://www.rfc-editor.org/rfc/rfc8628#section-3.4,
|
||||
// Device Access Token Request.
|
||||
type DeviceAccessTokenRequest struct {
|
||||
GrantType GrantType `json:"grant_type" schema:"grant_type"`
|
||||
DeviceCode string `json:"device_code" schema:"device_code"`
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
package oidc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDeviceAuthorizationResponse_UnmarshalJSON(t *testing.T) {
|
||||
jsonStr := `{
|
||||
"device_code": "deviceCode",
|
||||
"user_code": "userCode",
|
||||
"verification_url": "http://example.com/verify",
|
||||
"expires_in": 3600,
|
||||
"interval": 5
|
||||
}`
|
||||
|
||||
expected := &DeviceAuthorizationResponse{
|
||||
DeviceCode: "deviceCode",
|
||||
UserCode: "userCode",
|
||||
VerificationURI: "http://example.com/verify",
|
||||
ExpiresIn: 3600,
|
||||
Interval: 5,
|
||||
}
|
||||
|
||||
var resp DeviceAuthorizationResponse
|
||||
err := resp.UnmarshalJSON([]byte(jsonStr))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, &resp)
|
||||
}
|
|
@ -1,5 +1,9 @@
|
|||
package oidc
|
||||
|
||||
import (
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
const (
|
||||
DiscoveryEndpoint = "/.well-known/openid-configuration"
|
||||
)
|
||||
|
@ -26,8 +30,6 @@ type DiscoveryConfiguration struct {
|
|||
// EndSessionEndpoint is a URL where the RP can perform a redirect to request that the End-User be logged out at the OP.
|
||||
EndSessionEndpoint string `json:"end_session_endpoint,omitempty"`
|
||||
|
||||
DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint,omitempty"`
|
||||
|
||||
// CheckSessionIframe is a URL where the OP provides an iframe that support cross-origin communications for session state information with the RP Client.
|
||||
CheckSessionIframe string `json:"check_session_iframe,omitempty"`
|
||||
|
||||
|
@ -126,10 +128,10 @@ type DiscoveryConfiguration struct {
|
|||
ServiceDocumentation string `json:"service_documentation,omitempty"`
|
||||
|
||||
// ClaimsLocalesSupported contains a list of BCP47 language tag values that the OP supports for values of Claims returned.
|
||||
ClaimsLocalesSupported Locales `json:"claims_locales_supported,omitempty"`
|
||||
ClaimsLocalesSupported []language.Tag `json:"claims_locales_supported,omitempty"`
|
||||
|
||||
// UILocalesSupported contains a list of BCP47 language tag values that the OP supports for the user interface.
|
||||
UILocalesSupported Locales `json:"ui_locales_supported,omitempty"`
|
||||
UILocalesSupported []language.Tag `json:"ui_locales_supported,omitempty"`
|
||||
|
||||
// RequestParameterSupported specifies whether the OP supports use of the `request` parameter. If omitted, the default value is false.
|
||||
RequestParameterSupported bool `json:"request_parameter_supported,omitempty"`
|
||||
|
@ -145,14 +147,6 @@ type DiscoveryConfiguration struct {
|
|||
|
||||
// OPTermsOfServiceURI is a URL the OpenID Provider provides to the person registering the Client to read about OpenID Provider's terms of service.
|
||||
OPTermsOfServiceURI string `json:"op_tos_uri,omitempty"`
|
||||
|
||||
// BackChannelLogoutSupported specifies whether the OP supports back-channel logout (https://openid.net/specs/openid-connect-backchannel-1_0.html),
|
||||
// with true indicating support. If omitted, the default value is false.
|
||||
BackChannelLogoutSupported bool `json:"backchannel_logout_supported,omitempty"`
|
||||
|
||||
// BackChannelLogoutSessionSupported specifies whether the OP can pass a sid (session ID) Claim in the Logout Token to identify the RP session with the OP.
|
||||
// If supported, the sid Claim is also included in ID Tokens issued by the OP. If omitted, the default value is false.
|
||||
BackChannelLogoutSessionSupported bool `json:"backchannel_logout_session_supported,omitempty"`
|
||||
}
|
||||
|
||||
type AuthMethod string
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
package oidc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type errorType string
|
||||
|
@ -20,19 +18,6 @@ const (
|
|||
InteractionRequired errorType = "interaction_required"
|
||||
LoginRequired errorType = "login_required"
|
||||
RequestNotSupported errorType = "request_not_supported"
|
||||
|
||||
// Additional error codes as defined in
|
||||
// https://www.rfc-editor.org/rfc/rfc8628#section-3.5
|
||||
// Device Access Token Response
|
||||
AuthorizationPending errorType = "authorization_pending"
|
||||
SlowDown errorType = "slow_down"
|
||||
AccessDenied errorType = "access_denied"
|
||||
ExpiredToken errorType = "expired_token"
|
||||
|
||||
// InvalidTarget error is returned by Token Exchange if
|
||||
// the requested target or audience is invalid.
|
||||
// [RFC 8693, Section 2.2.2: Error Response](https://www.rfc-editor.org/rfc/rfc8693#section-2.2.2)
|
||||
InvalidTarget errorType = "invalid_target"
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -92,40 +77,6 @@ var (
|
|||
ErrorType: RequestNotSupported,
|
||||
}
|
||||
}
|
||||
|
||||
// Device Access Token errors:
|
||||
ErrAuthorizationPending = func() *Error {
|
||||
return &Error{
|
||||
ErrorType: AuthorizationPending,
|
||||
Description: "The client SHOULD repeat the access token request to the token endpoint, after interval from device authorization response.",
|
||||
}
|
||||
}
|
||||
ErrSlowDown = func() *Error {
|
||||
return &Error{
|
||||
ErrorType: SlowDown,
|
||||
Description: "Polling should continue, but the interval MUST be increased by 5 seconds for this and all subsequent requests.",
|
||||
}
|
||||
}
|
||||
ErrAccessDenied = func() *Error {
|
||||
return &Error{
|
||||
ErrorType: AccessDenied,
|
||||
Description: "The authorization request was denied.",
|
||||
}
|
||||
}
|
||||
ErrExpiredDeviceCode = func() *Error {
|
||||
return &Error{
|
||||
ErrorType: ExpiredToken,
|
||||
Description: "The \"device_code\" has expired.",
|
||||
}
|
||||
}
|
||||
|
||||
// Token exchange error
|
||||
ErrInvalidTarget = func() *Error {
|
||||
return &Error{
|
||||
ErrorType: InvalidTarget,
|
||||
Description: "The requested audience or target is invalid.",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type Error struct {
|
||||
|
@ -133,28 +84,7 @@ type Error struct {
|
|||
ErrorType errorType `json:"error" schema:"error"`
|
||||
Description string `json:"error_description,omitempty" schema:"error_description,omitempty"`
|
||||
State string `json:"state,omitempty" schema:"state,omitempty"`
|
||||
SessionState string `json:"session_state,omitempty" schema:"session_state,omitempty"`
|
||||
redirectDisabled bool `schema:"-"`
|
||||
returnParent bool `schema:"-"`
|
||||
}
|
||||
|
||||
func (e *Error) MarshalJSON() ([]byte, error) {
|
||||
m := struct {
|
||||
Error errorType `json:"error"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
SessionState string `json:"session_state,omitempty"`
|
||||
Parent string `json:"parent,omitempty"`
|
||||
}{
|
||||
Error: e.ErrorType,
|
||||
ErrorDescription: e.Description,
|
||||
State: e.State,
|
||||
SessionState: e.SessionState,
|
||||
}
|
||||
if e.returnParent {
|
||||
m.Parent = e.Parent.Error()
|
||||
}
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
|
@ -179,8 +109,7 @@ func (e *Error) Is(target error) bool {
|
|||
}
|
||||
return e.ErrorType == t.ErrorType &&
|
||||
(e.Description == t.Description || t.Description == "") &&
|
||||
(e.State == t.State || t.State == "") &&
|
||||
(e.SessionState == t.SessionState || t.SessionState == "")
|
||||
(e.State == t.State || t.State == "")
|
||||
}
|
||||
|
||||
func (e *Error) WithParent(err error) *Error {
|
||||
|
@ -188,19 +117,7 @@ func (e *Error) WithParent(err error) *Error {
|
|||
return e
|
||||
}
|
||||
|
||||
// WithReturnParentToClient allows returning the set parent error to the HTTP client.
|
||||
// Currently it only supports setting the parent inside JSON responses, not redirect URLs.
|
||||
// As Go errors don't unmarshal well, only the marshaller is implemented for the moment.
|
||||
//
|
||||
// Warning: parent errors may contain sensitive data or unwanted details about the server status.
|
||||
// Also, the `parent` field is not a standard error field and might confuse certain clients
|
||||
// that require fully compliant responses.
|
||||
func (e *Error) WithReturnParentToClient(b bool) *Error {
|
||||
e.returnParent = b
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *Error) WithDescription(desc string, args ...any) *Error {
|
||||
func (e *Error) WithDescription(desc string, args ...interface{}) *Error {
|
||||
e.Description = fmt.Sprintf(desc, args...)
|
||||
return e
|
||||
}
|
||||
|
@ -220,37 +137,3 @@ func DefaultToServerError(err error, description string) *Error {
|
|||
}
|
||||
return oauth
|
||||
}
|
||||
|
||||
func (e *Error) LogLevel() slog.Level {
|
||||
level := slog.LevelWarn
|
||||
if e.ErrorType == ServerError {
|
||||
level = slog.LevelError
|
||||
}
|
||||
if e.ErrorType == AuthorizationPending {
|
||||
level = slog.LevelInfo
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
func (e *Error) LogValue() slog.Value {
|
||||
attrs := make([]slog.Attr, 0, 5)
|
||||
if e.Parent != nil {
|
||||
attrs = append(attrs, slog.Any("parent", e.Parent))
|
||||
}
|
||||
if e.Description != "" {
|
||||
attrs = append(attrs, slog.String("description", e.Description))
|
||||
}
|
||||
if e.ErrorType != "" {
|
||||
attrs = append(attrs, slog.String("type", string(e.ErrorType)))
|
||||
}
|
||||
if e.State != "" {
|
||||
attrs = append(attrs, slog.String("state", e.State))
|
||||
}
|
||||
if e.SessionState != "" {
|
||||
attrs = append(attrs, slog.String("session_state", e.SessionState))
|
||||
}
|
||||
if e.redirectDisabled {
|
||||
attrs = append(attrs, slog.Bool("redirect_disabled", e.redirectDisabled))
|
||||
}
|
||||
return slog.GroupValue(attrs...)
|
||||
}
|
||||
|
|
|
@ -1,192 +0,0 @@
|
|||
package oidc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDefaultToServerError(t *testing.T) {
|
||||
type args struct {
|
||||
err error
|
||||
description string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *Error
|
||||
}{
|
||||
{
|
||||
name: "default",
|
||||
args: args{
|
||||
err: io.ErrClosedPipe,
|
||||
description: "oops",
|
||||
},
|
||||
want: &Error{
|
||||
ErrorType: ServerError,
|
||||
Description: "oops",
|
||||
Parent: io.ErrClosedPipe,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "our Error",
|
||||
args: args{
|
||||
err: ErrAccessDenied(),
|
||||
description: "oops",
|
||||
},
|
||||
want: &Error{
|
||||
ErrorType: AccessDenied,
|
||||
Description: "The authorization request was denied.",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := DefaultToServerError(tt.args.err, tt.args.description)
|
||||
assert.ErrorIs(t, got, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestError_LogLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err *Error
|
||||
want slog.Level
|
||||
}{
|
||||
{
|
||||
name: "server error",
|
||||
err: ErrServerError(),
|
||||
want: slog.LevelError,
|
||||
},
|
||||
{
|
||||
name: "authorization pending",
|
||||
err: ErrAuthorizationPending(),
|
||||
want: slog.LevelInfo,
|
||||
},
|
||||
{
|
||||
name: "some other error",
|
||||
err: ErrAccessDenied(),
|
||||
want: slog.LevelWarn,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.err.LogLevel()
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestError_LogValue(t *testing.T) {
|
||||
type fields struct {
|
||||
Parent error
|
||||
ErrorType errorType
|
||||
Description string
|
||||
State string
|
||||
redirectDisabled bool
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
want slog.Value
|
||||
}{
|
||||
{
|
||||
name: "parent",
|
||||
fields: fields{
|
||||
Parent: io.EOF,
|
||||
},
|
||||
want: slog.GroupValue(slog.Any("parent", io.EOF)),
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
fields: fields{
|
||||
Description: "oops",
|
||||
},
|
||||
want: slog.GroupValue(slog.String("description", "oops")),
|
||||
},
|
||||
{
|
||||
name: "errorType",
|
||||
fields: fields{
|
||||
ErrorType: ExpiredToken,
|
||||
},
|
||||
want: slog.GroupValue(slog.String("type", string(ExpiredToken))),
|
||||
},
|
||||
{
|
||||
name: "state",
|
||||
fields: fields{
|
||||
State: "123",
|
||||
},
|
||||
want: slog.GroupValue(slog.String("state", "123")),
|
||||
},
|
||||
{
|
||||
name: "all fields",
|
||||
fields: fields{
|
||||
Parent: io.EOF,
|
||||
Description: "oops",
|
||||
ErrorType: ExpiredToken,
|
||||
State: "123",
|
||||
},
|
||||
want: slog.GroupValue(
|
||||
slog.Any("parent", io.EOF),
|
||||
slog.String("description", "oops"),
|
||||
slog.String("type", string(ExpiredToken)),
|
||||
slog.String("state", "123"),
|
||||
),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
e := &Error{
|
||||
Parent: tt.fields.Parent,
|
||||
ErrorType: tt.fields.ErrorType,
|
||||
Description: tt.fields.Description,
|
||||
State: tt.fields.State,
|
||||
redirectDisabled: tt.fields.redirectDisabled,
|
||||
}
|
||||
got := e.LogValue()
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestError_MarshalJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
e *Error
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "simple error",
|
||||
e: ErrAccessDenied(),
|
||||
want: `{"error":"access_denied","error_description":"The authorization request was denied."}`,
|
||||
},
|
||||
{
|
||||
name: "with description",
|
||||
e: ErrAccessDenied().WithDescription("oops"),
|
||||
want: `{"error":"access_denied","error_description":"oops"}`,
|
||||
},
|
||||
{
|
||||
name: "with parent",
|
||||
e: ErrServerError().WithParent(errors.New("oops")),
|
||||
want: `{"error":"server_error"}`,
|
||||
},
|
||||
{
|
||||
name: "with return parent",
|
||||
e: ErrServerError().WithParent(errors.New("oops")).WithReturnParentToClient(true),
|
||||
want: `{"error":"server_error","parent":"oops"}`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := json.Marshal(tt.e)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, tt.want, string(got))
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,6 +1,12 @@
|
|||
package oidc
|
||||
|
||||
import "github.com/muhlemmer/gu"
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
type IntrospectionRequest struct {
|
||||
Token string `schema:"token"`
|
||||
|
@ -11,69 +17,364 @@ type ClientAssertionParams struct {
|
|||
ClientAssertionType string `schema:"client_assertion_type"`
|
||||
}
|
||||
|
||||
// IntrospectionResponse implements RFC 7662, section 2.2 and
|
||||
// OpenID Connect Core 1.0, section 5.1 (UserInfo).
|
||||
// https://www.rfc-editor.org/rfc/rfc7662.html#section-2.2.
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims.
|
||||
type IntrospectionResponse struct {
|
||||
type IntrospectionResponse interface {
|
||||
UserInfoSetter
|
||||
IsActive() bool
|
||||
SetActive(bool)
|
||||
SetScopes(scopes []string)
|
||||
SetClientID(id string)
|
||||
SetTokenType(tokenType string)
|
||||
SetExpiration(exp time.Time)
|
||||
SetIssuedAt(iat time.Time)
|
||||
SetNotBefore(nbf time.Time)
|
||||
SetAudience(audience []string)
|
||||
SetIssuer(issuer string)
|
||||
SetJWTID(id string)
|
||||
GetScope() []string
|
||||
GetClientID() string
|
||||
GetTokenType() string
|
||||
GetExpiration() time.Time
|
||||
GetIssuedAt() time.Time
|
||||
GetNotBefore() time.Time
|
||||
GetSubject() string
|
||||
GetAudience() []string
|
||||
GetIssuer() string
|
||||
GetJWTID() string
|
||||
}
|
||||
|
||||
func NewIntrospectionResponse() IntrospectionResponse {
|
||||
return &introspectionResponse{}
|
||||
}
|
||||
|
||||
type introspectionResponse struct {
|
||||
Active bool `json:"active"`
|
||||
Scope SpaceDelimitedArray `json:"scope,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
TokenType string `json:"token_type,omitempty"`
|
||||
Expiration Time `json:"exp,omitempty"`
|
||||
IssuedAt Time `json:"iat,omitempty"`
|
||||
AuthTime Time `json:"auth_time,omitempty"`
|
||||
NotBefore Time `json:"nbf,omitempty"`
|
||||
Subject string `json:"sub,omitempty"`
|
||||
Audience Audience `json:"aud,omitempty"`
|
||||
AuthenticationMethodsReferences []string `json:"amr,omitempty"`
|
||||
Issuer string `json:"iss,omitempty"`
|
||||
JWTID string `json:"jti,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Actor *ActorClaims `json:"act,omitempty"`
|
||||
UserInfoProfile
|
||||
UserInfoEmail
|
||||
UserInfoPhone
|
||||
userInfoProfile
|
||||
userInfoEmail
|
||||
userInfoPhone
|
||||
|
||||
Address *UserInfoAddress `json:"address,omitempty"`
|
||||
Claims map[string]any `json:"-"`
|
||||
Address UserInfoAddress `json:"address,omitempty"`
|
||||
claims map[string]interface{}
|
||||
}
|
||||
|
||||
// SetUserInfo copies all relevant fields from UserInfo
|
||||
// into the IntroSpectionResponse.
|
||||
func (i *IntrospectionResponse) SetUserInfo(u *UserInfo) {
|
||||
i.Subject = u.Subject
|
||||
i.Username = u.PreferredUsername
|
||||
i.Address = gu.PtrCopy(u.Address)
|
||||
i.UserInfoProfile = u.UserInfoProfile
|
||||
i.UserInfoEmail = u.UserInfoEmail
|
||||
i.UserInfoPhone = u.UserInfoPhone
|
||||
if i.Claims == nil {
|
||||
i.Claims = gu.MapCopy(u.Claims)
|
||||
} else {
|
||||
gu.MapMerge(u.Claims, i.Claims)
|
||||
}
|
||||
func (i *introspectionResponse) IsActive() bool {
|
||||
return i.Active
|
||||
}
|
||||
|
||||
// GetAddress is a safe getter that takes
|
||||
// care of a possible nil value.
|
||||
func (i *IntrospectionResponse) GetAddress() *UserInfoAddress {
|
||||
if i.Address == nil {
|
||||
return new(UserInfoAddress)
|
||||
func (i *introspectionResponse) GetSubject() string {
|
||||
return i.Subject
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) GetName() string {
|
||||
return i.Name
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) GetGivenName() string {
|
||||
return i.GivenName
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) GetFamilyName() string {
|
||||
return i.FamilyName
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) GetMiddleName() string {
|
||||
return i.MiddleName
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) GetNickname() string {
|
||||
return i.Nickname
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) GetProfile() string {
|
||||
return i.Profile
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) GetPicture() string {
|
||||
return i.Picture
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) GetWebsite() string {
|
||||
return i.Website
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) GetGender() Gender {
|
||||
return i.Gender
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) GetBirthdate() string {
|
||||
return i.Birthdate
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) GetZoneinfo() string {
|
||||
return i.Zoneinfo
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) GetLocale() language.Tag {
|
||||
return i.Locale
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) GetPreferredUsername() string {
|
||||
return i.PreferredUsername
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) GetEmail() string {
|
||||
return i.Email
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) IsEmailVerified() bool {
|
||||
return bool(i.EmailVerified)
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) GetPhoneNumber() string {
|
||||
return i.PhoneNumber
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) IsPhoneNumberVerified() bool {
|
||||
return i.PhoneNumberVerified
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) GetAddress() UserInfoAddress {
|
||||
return i.Address
|
||||
}
|
||||
|
||||
// introspectionResponseAlias prevents loops on the JSON methods
|
||||
type introspectionResponseAlias IntrospectionResponse
|
||||
|
||||
func (i *IntrospectionResponse) MarshalJSON() ([]byte, error) {
|
||||
if i.Username == "" {
|
||||
i.Username = i.PreferredUsername
|
||||
}
|
||||
return mergeAndMarshalClaims((*introspectionResponseAlias)(i), i.Claims)
|
||||
func (i *introspectionResponse) GetClaim(key string) interface{} {
|
||||
return i.claims[key]
|
||||
}
|
||||
|
||||
func (i *IntrospectionResponse) UnmarshalJSON(data []byte) error {
|
||||
return unmarshalJSONMulti(data, (*introspectionResponseAlias)(i), &i.Claims)
|
||||
func (i *introspectionResponse) GetClaims() map[string]interface{} {
|
||||
return i.claims
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) GetScope() []string {
|
||||
return []string(i.Scope)
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) GetClientID() string {
|
||||
return i.ClientID
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) GetTokenType() string {
|
||||
return i.TokenType
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) GetExpiration() time.Time {
|
||||
return time.Time(i.Expiration)
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) GetIssuedAt() time.Time {
|
||||
return time.Time(i.IssuedAt)
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) GetNotBefore() time.Time {
|
||||
return time.Time(i.NotBefore)
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) GetAudience() []string {
|
||||
return []string(i.Audience)
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) GetIssuer() string {
|
||||
return i.Issuer
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) GetJWTID() string {
|
||||
return i.JWTID
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetActive(active bool) {
|
||||
i.Active = active
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetScopes(scope []string) {
|
||||
i.Scope = scope
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetClientID(id string) {
|
||||
i.ClientID = id
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetTokenType(tokenType string) {
|
||||
i.TokenType = tokenType
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetExpiration(exp time.Time) {
|
||||
i.Expiration = Time(exp)
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetIssuedAt(iat time.Time) {
|
||||
i.IssuedAt = Time(iat)
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetNotBefore(nbf time.Time) {
|
||||
i.NotBefore = Time(nbf)
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetAudience(audience []string) {
|
||||
i.Audience = audience
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetIssuer(issuer string) {
|
||||
i.Issuer = issuer
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetJWTID(id string) {
|
||||
i.JWTID = id
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetSubject(sub string) {
|
||||
i.Subject = sub
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetName(name string) {
|
||||
i.Name = name
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetGivenName(name string) {
|
||||
i.GivenName = name
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetFamilyName(name string) {
|
||||
i.FamilyName = name
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetMiddleName(name string) {
|
||||
i.MiddleName = name
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetNickname(name string) {
|
||||
i.Nickname = name
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetUpdatedAt(date time.Time) {
|
||||
i.UpdatedAt = Time(date)
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetProfile(profile string) {
|
||||
i.Profile = profile
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetPicture(picture string) {
|
||||
i.Picture = picture
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetWebsite(website string) {
|
||||
i.Website = website
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetGender(gender Gender) {
|
||||
i.Gender = gender
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetBirthdate(birthdate string) {
|
||||
i.Birthdate = birthdate
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetZoneinfo(zoneInfo string) {
|
||||
i.Zoneinfo = zoneInfo
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetLocale(locale language.Tag) {
|
||||
i.Locale = locale
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetPreferredUsername(name string) {
|
||||
i.PreferredUsername = name
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetEmail(email string, verified bool) {
|
||||
i.Email = email
|
||||
i.EmailVerified = boolString(verified)
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetPhone(phone string, verified bool) {
|
||||
i.PhoneNumber = phone
|
||||
i.PhoneNumberVerified = verified
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) SetAddress(address UserInfoAddress) {
|
||||
i.Address = address
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) AppendClaims(key string, value interface{}) {
|
||||
if i.claims == nil {
|
||||
i.claims = make(map[string]interface{})
|
||||
}
|
||||
i.claims[key] = value
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) MarshalJSON() ([]byte, error) {
|
||||
type Alias introspectionResponse
|
||||
a := &struct {
|
||||
*Alias
|
||||
Expiration int64 `json:"exp,omitempty"`
|
||||
IssuedAt int64 `json:"iat,omitempty"`
|
||||
NotBefore int64 `json:"nbf,omitempty"`
|
||||
Locale interface{} `json:"locale,omitempty"`
|
||||
UpdatedAt int64 `json:"updated_at,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
}{
|
||||
Alias: (*Alias)(i),
|
||||
}
|
||||
if !i.Locale.IsRoot() {
|
||||
a.Locale = i.Locale
|
||||
}
|
||||
if !time.Time(i.UpdatedAt).IsZero() {
|
||||
a.UpdatedAt = time.Time(i.UpdatedAt).Unix()
|
||||
}
|
||||
if !time.Time(i.Expiration).IsZero() {
|
||||
a.Expiration = time.Time(i.Expiration).Unix()
|
||||
}
|
||||
if !time.Time(i.IssuedAt).IsZero() {
|
||||
a.IssuedAt = time.Time(i.IssuedAt).Unix()
|
||||
}
|
||||
if !time.Time(i.NotBefore).IsZero() {
|
||||
a.NotBefore = time.Time(i.NotBefore).Unix()
|
||||
}
|
||||
a.Username = i.PreferredUsername
|
||||
|
||||
b, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(i.claims) == 0 {
|
||||
return b, nil
|
||||
}
|
||||
|
||||
err = json.Unmarshal(b, &i.claims)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jws: invalid map of custom claims %v", i.claims)
|
||||
}
|
||||
|
||||
return json.Marshal(i.claims)
|
||||
}
|
||||
|
||||
func (i *introspectionResponse) UnmarshalJSON(data []byte) error {
|
||||
type Alias introspectionResponse
|
||||
a := &struct {
|
||||
*Alias
|
||||
UpdatedAt int64 `json:"update_at,omitempty"`
|
||||
}{
|
||||
Alias: (*Alias)(i),
|
||||
}
|
||||
if err := json.Unmarshal(data, &a); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i.UpdatedAt = Time(time.Unix(a.UpdatedAt, 0).UTC())
|
||||
|
||||
if err := json.Unmarshal(data, &i.claims); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -1,79 +0,0 @@
|
|||
package oidc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/muhlemmer/gu"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIntrospectionResponse_SetUserInfo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
start *IntrospectionResponse
|
||||
want *IntrospectionResponse
|
||||
}{
|
||||
{
|
||||
|
||||
name: "nil claims",
|
||||
start: &IntrospectionResponse{},
|
||||
want: &IntrospectionResponse{
|
||||
Subject: userInfoData.Subject,
|
||||
Username: userInfoData.PreferredUsername,
|
||||
Address: userInfoData.Address,
|
||||
UserInfoProfile: userInfoData.UserInfoProfile,
|
||||
UserInfoEmail: userInfoData.UserInfoEmail,
|
||||
UserInfoPhone: userInfoData.UserInfoPhone,
|
||||
Claims: gu.MapCopy(userInfoData.Claims),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
name: "merge claims",
|
||||
start: &IntrospectionResponse{
|
||||
Claims: map[string]any{
|
||||
"hello": "world",
|
||||
},
|
||||
},
|
||||
want: &IntrospectionResponse{
|
||||
Subject: userInfoData.Subject,
|
||||
Username: userInfoData.PreferredUsername,
|
||||
Address: userInfoData.Address,
|
||||
UserInfoProfile: userInfoData.UserInfoProfile,
|
||||
UserInfoEmail: userInfoData.UserInfoEmail,
|
||||
UserInfoPhone: userInfoData.UserInfoPhone,
|
||||
Claims: map[string]any{
|
||||
"foo": "bar",
|
||||
"hello": "world",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.start.SetUserInfo(userInfoData)
|
||||
assert.Equal(t, tt.want, tt.start)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntrospectionResponse_GetAddress(t *testing.T) {
|
||||
// nil address
|
||||
i := new(IntrospectionResponse)
|
||||
assert.Equal(t, &UserInfoAddress{}, i.GetAddress())
|
||||
|
||||
i.Address = &UserInfoAddress{PostalCode: "1234"}
|
||||
assert.Equal(t, i.Address, i.GetAddress())
|
||||
}
|
||||
|
||||
func TestIntrospectionResponse_MarshalJSON(t *testing.T) {
|
||||
got, err := json.Marshal(&IntrospectionResponse{
|
||||
UserInfoProfile: UserInfoProfile{
|
||||
PreferredUsername: "muhlemmer",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(got), `{"active":false,"username":"muhlemmer","preferred_username":"muhlemmer"}`)
|
||||
}
|
|
@ -6,9 +6,8 @@ import (
|
|||
"crypto/ed25519"
|
||||
"crypto/rsa"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
jose "github.com/go-jose/go-jose/v4"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -92,18 +91,18 @@ func FindMatchingKey(keyID, use, expectedAlg string, keys ...jose.JSONWebKey) (k
|
|||
return key, ErrKeyNone
|
||||
}
|
||||
|
||||
func algToKeyType(key any, alg string) bool {
|
||||
if strings.HasPrefix(alg, "RS") || strings.HasPrefix(alg, "PS") {
|
||||
func algToKeyType(key interface{}, alg string) bool {
|
||||
switch alg[0] {
|
||||
case 'R', 'P':
|
||||
_, ok := key.(*rsa.PublicKey)
|
||||
return ok
|
||||
}
|
||||
if strings.HasPrefix(alg, "ES") {
|
||||
case 'E':
|
||||
_, ok := key.(*ecdsa.PublicKey)
|
||||
return ok
|
||||
}
|
||||
if alg == string(jose.EdDSA) {
|
||||
_, ok := key.(ed25519.PublicKey)
|
||||
case 'O':
|
||||
_, ok := key.(*ed25519.PublicKey)
|
||||
return ok
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import (
|
|||
"reflect"
|
||||
"testing"
|
||||
|
||||
jose "github.com/go-jose/go-jose/v4"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
)
|
||||
|
||||
func TestFindKey(t *testing.T) {
|
||||
|
|
|
@ -1,53 +0,0 @@
|
|||
//go:build !create_regression_data
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Test_assert_regression verifies current output from
|
||||
// json.Marshal to stored regression data.
|
||||
// These tests are only ran when the create_regression_data
|
||||
// tag is NOT set.
|
||||
func Test_assert_regression(t *testing.T) {
|
||||
buf := new(strings.Builder)
|
||||
|
||||
for _, obj := range regressionData {
|
||||
name := jsonFilename(obj)
|
||||
t.Run(name, func(t *testing.T) {
|
||||
file, err := os.Open(name)
|
||||
require.NoError(t, err)
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(buf, file)
|
||||
require.NoError(t, err)
|
||||
want := buf.String()
|
||||
buf.Reset()
|
||||
|
||||
encodeJSON(t, buf, obj)
|
||||
first := buf.String()
|
||||
buf.Reset()
|
||||
|
||||
assert.JSONEq(t, want, first)
|
||||
|
||||
target := reflect.New(reflect.TypeOf(obj).Elem()).Interface()
|
||||
|
||||
require.NoError(t,
|
||||
json.Unmarshal([]byte(first), target),
|
||||
)
|
||||
second, err := json.Marshal(target)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.JSONEq(t, want, string(second))
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
//go:build create_regression_data
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Test_create_regression generates the regression data.
|
||||
// It is excluded from regular testing, unless
|
||||
// called with the create_regression_data tag:
|
||||
// go test -tags="create_regression_data" ./pkg/oidc
|
||||
func Test_create_regression(t *testing.T) {
|
||||
for _, obj := range regressionData {
|
||||
file, err := os.Create(jsonFilename(obj))
|
||||
require.NoError(t, err)
|
||||
defer file.Close()
|
||||
|
||||
encodeJSON(t, file, obj)
|
||||
}
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
{
|
||||
"iss": "zitadel",
|
||||
"sub": "hello@me.com",
|
||||
"aud": [
|
||||
"foo",
|
||||
"bar"
|
||||
],
|
||||
"jti": "900",
|
||||
"azp": "just@me.com",
|
||||
"nonce": "6969",
|
||||
"acr": "something",
|
||||
"amr": [
|
||||
"some",
|
||||
"methods"
|
||||
],
|
||||
"scope": "email phone",
|
||||
"client_id": "777",
|
||||
"exp": 12345,
|
||||
"iat": 12000,
|
||||
"nbf": 12000,
|
||||
"auth_time": 12000,
|
||||
"foo": "bar"
|
||||
}
|
|
@ -1,51 +0,0 @@
|
|||
{
|
||||
"iss": "zitadel",
|
||||
"aud": [
|
||||
"foo",
|
||||
"bar"
|
||||
],
|
||||
"jti": "900",
|
||||
"azp": "just@me.com",
|
||||
"nonce": "6969",
|
||||
"at_hash": "acthashhash",
|
||||
"c_hash": "hashhash",
|
||||
"acr": "something",
|
||||
"amr": [
|
||||
"some",
|
||||
"methods"
|
||||
],
|
||||
"sid": "666",
|
||||
"client_id": "777",
|
||||
"exp": 12345,
|
||||
"iat": 12000,
|
||||
"nbf": 12000,
|
||||
"auth_time": 12000,
|
||||
"address": {
|
||||
"country": "Moon",
|
||||
"formatted": "Sesame street 666\n666-666, Smallvile\nMoon",
|
||||
"locality": "Smallvile",
|
||||
"postal_code": "666-666",
|
||||
"region": "Outer space",
|
||||
"street_address": "Sesame street 666"
|
||||
},
|
||||
"birthdate": "1st of April",
|
||||
"email": "tim@zitadel.com",
|
||||
"email_verified": true,
|
||||
"family_name": "Möhlmann",
|
||||
"foo": "bar",
|
||||
"gender": "male",
|
||||
"given_name": "Tim",
|
||||
"locale": "nl",
|
||||
"middle_name": "Danger",
|
||||
"name": "Tim Möhlmann",
|
||||
"nickname": "muhlemmer",
|
||||
"phone_number": "+1234567890",
|
||||
"phone_number_verified": true,
|
||||
"picture": "https://avatars.githubusercontent.com/u/5411563?v=4",
|
||||
"preferred_username": "muhlemmer",
|
||||
"profile": "https://github.com/muhlemmer",
|
||||
"sub": "hello@me.com",
|
||||
"updated_at": 1,
|
||||
"website": "https://zitadel.com",
|
||||
"zoneinfo": "Europe/Amsterdam"
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
{
|
||||
"active": true,
|
||||
"address": {
|
||||
"country": "Moon",
|
||||
"formatted": "Sesame street 666\n666-666, Smallvile\nMoon",
|
||||
"locality": "Smallvile",
|
||||
"postal_code": "666-666",
|
||||
"region": "Outer space",
|
||||
"street_address": "Sesame street 666"
|
||||
},
|
||||
"aud": [
|
||||
"foo",
|
||||
"bar"
|
||||
],
|
||||
"birthdate": "1st of April",
|
||||
"client_id": "777",
|
||||
"email": "tim@zitadel.com",
|
||||
"email_verified": true,
|
||||
"exp": 12345,
|
||||
"family_name": "Möhlmann",
|
||||
"foo": "bar",
|
||||
"gender": "male",
|
||||
"given_name": "Tim",
|
||||
"iat": 12000,
|
||||
"iss": "zitadel",
|
||||
"jti": "900",
|
||||
"locale": "nl",
|
||||
"middle_name": "Danger",
|
||||
"name": "Tim Möhlmann",
|
||||
"nbf": 12000,
|
||||
"nickname": "muhlemmer",
|
||||
"phone_number": "+1234567890",
|
||||
"phone_number_verified": true,
|
||||
"picture": "https://avatars.githubusercontent.com/u/5411563?v=4",
|
||||
"preferred_username": "muhlemmer",
|
||||
"profile": "https://github.com/muhlemmer",
|
||||
"scope": "email phone",
|
||||
"sub": "hello@me.com",
|
||||
"token_type": "idtoken",
|
||||
"updated_at": 1,
|
||||
"username": "muhlemmer",
|
||||
"website": "https://zitadel.com",
|
||||
"zoneinfo": "Europe/Amsterdam"
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"aud": [
|
||||
"foo",
|
||||
"bar"
|
||||
],
|
||||
"exp": 12345,
|
||||
"foo": "bar",
|
||||
"iat": 12000,
|
||||
"iss": "zitadel",
|
||||
"sub": "hello@me.com"
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
{
|
||||
"address": {
|
||||
"country": "Moon",
|
||||
"formatted": "Sesame street 666\n666-666, Smallvile\nMoon",
|
||||
"locality": "Smallvile",
|
||||
"postal_code": "666-666",
|
||||
"region": "Outer space",
|
||||
"street_address": "Sesame street 666"
|
||||
},
|
||||
"birthdate": "1st of April",
|
||||
"email": "tim@zitadel.com",
|
||||
"email_verified": true,
|
||||
"family_name": "Möhlmann",
|
||||
"foo": "bar",
|
||||
"gender": "male",
|
||||
"given_name": "Tim",
|
||||
"locale": "nl",
|
||||
"middle_name": "Danger",
|
||||
"name": "Tim Möhlmann",
|
||||
"nickname": "muhlemmer",
|
||||
"phone_number": "+1234567890",
|
||||
"phone_number_verified": true,
|
||||
"picture": "https://avatars.githubusercontent.com/u/5411563?v=4",
|
||||
"preferred_username": "muhlemmer",
|
||||
"profile": "https://github.com/muhlemmer",
|
||||
"sub": "hello@me.com",
|
||||
"updated_at": 1,
|
||||
"website": "https://zitadel.com",
|
||||
"zoneinfo": "Europe/Amsterdam"
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue