diff --git a/.github/workflows/issue.yml b/.github/workflows/issue.yml
index 5b1febf..480c339 100644
--- a/.github/workflows/issue.yml
+++ b/.github/workflows/issue.yml
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: add issue
- uses: actions/add-to-project@v1.0.1
+ uses: actions/add-to-project@v1.0.2
if: ${{ github.event_name == 'issues' }}
with:
# You can target a repository in a different organization
@@ -28,7 +28,7 @@ jobs:
username: ${{ github.actor }}
GITHUB_TOKEN: ${{ secrets.ADD_TO_PROJECT_PAT }}
- name: add pr
- uses: actions/add-to-project@v1.0.1
+ 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
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e149a84..9969c58 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -18,7 +18,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- go: ['1.21', '1.22']
+ go: ['1.21', '1.22', '1.23']
name: Go ${{ matrix.go }} test
steps:
- uses: actions/checkout@v4
@@ -27,7 +27,7 @@ jobs:
with:
go-version: ${{ matrix.go }}
- run: go test -race -v -coverprofile=profile.cov -coverpkg=./pkg/... ./pkg/...
- - uses: codecov/codecov-action@v4.3.1
+ - uses: codecov/codecov-action@v5.3.1
with:
file: ./profile.cov
name: codecov-go
diff --git a/README.md b/README.md
index 01d7d47..04d551f 100644
--- a/README.md
+++ b/README.md
@@ -21,9 +21,10 @@ Whenever possible we tried to reuse / extend existing packages like `OAuth2 for
## Basic Overview
The most important packages of the library:
+
/pkg
- /client clients using the OP for retrieving, exchanging and verifying tokens
+ /client clients using the OP for retrieving, exchanging and verifying tokens
/rp definition and implementation of an OIDC Relying Party (client)
/rs definition and implementation of an OAuth Resource Server (API)
/op definition and implementation of an OIDC OpenID Provider (server)
@@ -37,7 +38,6 @@ The most important packages of the library:
/server examples of an OpenID Provider implementations (including dynamic) with some very basic login UI
-
### 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.
@@ -55,48 +55,84 @@ CLIENT_ID=web CLIENT_SECRET=secret ISSUER=http://localhost:9998/ SCOPES="openid
```
- open http://localhost:9999/login in your browser
-- you will be redirected to op server and the login UI
+- you will be redirected to op server and the login UI
- login with user `test-user@localhost` 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] |
+| | 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]: "3.1. Authentication using the Authorization Code Flow"
-[2]: "3.2. Authentication using the Implicit Flow"
-[3]: "3.3. Authentication using the Hybrid Flow"
-[4]: "9. Client Authentication"
-[5]: "12. Using Refresh Tokens"
-[6]: "OpenID Connect Discovery 1.0 incorporating errata set 1"
-[7]: "JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants"
-[8]: "Proof Key for Code Exchange by OAuth Public Clients"
-[9]: "OAuth 2.0 Token Exchange"
-[10]: "OAuth 2.0 Device Authorization Grant"
-[11]: "OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens"
+[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"
## Contributors
@@ -115,14 +151,15 @@ For your convenience you can find the relevant guides linked below.
## Supported Go Versions
-For security reasons, we only support and recommend the use of one of the latest two Go versions (:white_check_mark:).
+For security reasons, we only support and recommend the use of one of the latest two Go versions (:white_check_mark:).
Versions that also build are marked with :warning:.
| Version | Supported |
| ------- | ------------------ |
| <1.21 | :x: |
-| 1.21 | :white_check_mark: |
+| 1.21 | :warning: |
| 1.22 | :white_check_mark: |
+| 1.23 | :white_check_mark: |
## Why another library
@@ -153,5 +190,4 @@ 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
diff --git a/example/client/app/app.go b/example/client/app/app.go
index 99aba3d..0b9b19d 100644
--- a/example/client/app/app.go
+++ b/example/client/app/app.go
@@ -56,6 +56,7 @@ func main() {
rp.WithVerifierOpts(rp.WithIssuedAtOffset(5 * time.Second)),
rp.WithHTTPClient(client),
rp.WithLogger(logger),
+ rp.WithSigningAlgsFromDiscovery(),
}
if clientSecret == "" {
options = append(options, rp.WithPKCE(cookieHandler))
@@ -108,6 +109,7 @@ func main() {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
+ w.Header().Set("content-type", "application/json")
w.Write(data)
}
diff --git a/example/server/config/config.go b/example/server/config/config.go
new file mode 100644
index 0000000..96837d4
--- /dev/null
+++ b/example/server/config/config.go
@@ -0,0 +1,40 @@
+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
+}
diff --git a/example/server/config/config_test.go b/example/server/config/config_test.go
new file mode 100644
index 0000000..3b73c0b
--- /dev/null
+++ b/example/server/config/config_test.go
@@ -0,0 +1,77 @@
+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)
+ }
+ })
+ }
+}
diff --git a/example/server/exampleop/op.go b/example/server/exampleop/op.go
index e8ef892..8f55b0a 100644
--- a/example/server/exampleop/op.go
+++ b/example/server/exampleop/op.go
@@ -12,7 +12,6 @@ import (
"github.com/zitadel/logging"
"golang.org/x/text/language"
- "github.com/zitadel/oidc/v3/example/server/storage"
"github.com/zitadel/oidc/v3/pkg/op"
)
@@ -20,14 +19,6 @@ const (
pathLoggedOut = "/logged-out"
)
-func init() {
- storage.RegisterClients(
- storage.NativeClient("native"),
- storage.WebClient("web", "secret"),
- storage.WebClient("api", "secret"),
- )
-}
-
type Storage interface {
op.Storage
authenticate
diff --git a/example/server/main.go b/example/server/main.go
index a2ad190..6d345e1 100644
--- a/example/server/main.go
+++ b/example/server/main.go
@@ -6,36 +6,53 @@ import (
"net/http"
"os"
+ "github.com/zitadel/oidc/v3/example/server/config"
"github.com/zitadel/oidc/v3/example/server/exampleop"
"github.com/zitadel/oidc/v3/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() {
- //we will run on :9998
- port := "9998"
- //which gives us the issuer: http://localhost:9998/
- issuer := fmt.Sprintf("http://localhost:%s/", port)
-
- // 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
- storage := storage.NewStorage(storage.NewUserStore(issuer))
-
+ 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...),
+ )
+
+ // 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)
server := &http.Server{
- Addr: ":" + port,
+ Addr: ":" + cfg.Port,
Handler: router,
}
- logger.Info("server listening, press ctrl+c to stop", "addr", fmt.Sprintf("http://localhost:%s/", port))
- err := server.ListenAndServe()
- if err != http.ErrServerClosed {
+ 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)
}
diff --git a/example/server/storage/oidc.go b/example/server/storage/oidc.go
index 9cd08d9..22c0295 100644
--- a/example/server/storage/oidc.go
+++ b/example/server/storage/oidc.go
@@ -121,7 +121,7 @@ func (a *AuthRequest) Done() bool {
}
func PromptToInternal(oidcPrompt oidc.SpaceDelimitedArray) []string {
- prompts := make([]string, len(oidcPrompt))
+ prompts := make([]string, 0, len(oidcPrompt))
for _, oidcPrompt := range oidcPrompt {
switch oidcPrompt {
case oidc.PromptNone,
diff --git a/example/server/storage/storage.go b/example/server/storage/storage.go
index d8b7a5d..1bc2e94 100644
--- a/example/server/storage/storage.go
+++ b/example/server/storage/storage.go
@@ -151,6 +151,9 @@ func (s *Storage) CheckUsernamePassword(username, password, id string) error {
// 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")
@@ -385,14 +388,9 @@ 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")
}
- // if it is a refresh token, you will have to remove the access token as well
delete(s.refreshTokens, refreshToken.ID)
- for _, accessToken := range s.tokens {
- if accessToken.RefreshTokenID == refreshToken.ID {
- delete(s.tokens, accessToken.ID)
- return nil
- }
- }
+ // if it is a refresh token, you will have to remove the access token as well
+ delete(s.tokens, refreshToken.AccessToken)
return nil
}
@@ -488,6 +486,9 @@ 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)
}
@@ -594,12 +595,17 @@ 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 string) (string, string, error) {
s.lock.Lock()
defer s.lock.Unlock()
@@ -607,18 +613,21 @@ func (s *Storage) renewRefreshToken(currentRefreshToken string) (string, string,
if !ok {
return "", "", fmt.Errorf("invalid refresh token")
}
- // deletes the refresh token and all access tokens which were issued based on this refresh token
+ // deletes the refresh token
delete(s.refreshTokens, currentRefreshToken)
- for _, token := range s.tokens {
- if token.RefreshTokenID == currentRefreshToken {
- delete(s.tokens, token.ID)
- break
- }
+
+ // 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")
}
+
// creates a new refresh token based on the current one
token := uuid.NewString()
refreshToken.Token = token
refreshToken.ID = token
+ refreshToken.Expiration = time.Now().Add(5 * time.Hour)
s.refreshTokens[token] = refreshToken
return token, refreshToken.ID, nil
}
diff --git a/example/server/storage/token.go b/example/server/storage/token.go
index ad907e3..beab38c 100644
--- a/example/server/storage/token.go
+++ b/example/server/storage/token.go
@@ -22,4 +22,5 @@ type RefreshToken struct {
ApplicationID string
Expiration time.Time
Scopes []string
+ AccessToken string // Token.ID
}
diff --git a/example/server/storage/user.go b/example/server/storage/user.go
index 173daef..ed8cdfa 100644
--- a/example/server/storage/user.go
+++ b/example/server/storage/user.go
@@ -2,6 +2,8 @@ package storage
import (
"crypto/rsa"
+ "encoding/json"
+ "os"
"strings"
"golang.org/x/text/language"
@@ -35,6 +37,18 @@ 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]
return userStore{
diff --git a/example/server/storage/user_test.go b/example/server/storage/user_test.go
new file mode 100644
index 0000000..c2e2212
--- /dev/null
+++ b/example/server/storage/user_test.go
@@ -0,0 +1,70 @@
+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)
+ }
+ })
+ }
+}
diff --git a/go.mod b/go.mod
index e00f653..a4a71b9 100644
--- a/go.mod
+++ b/go.mod
@@ -3,36 +3,36 @@ module github.com/zitadel/oidc/v3
go 1.21
require (
- github.com/bmatcuk/doublestar/v4 v4.6.1
- github.com/go-chi/chi/v5 v5.0.12
- github.com/go-jose/go-jose/v4 v4.0.2
+ 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.4
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.7
+ 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.0
+ github.com/rs/cors v1.11.1
github.com/sirupsen/logrus v1.9.3
- github.com/stretchr/testify v1.9.0
- github.com/zitadel/logging v0.6.0
+ github.com/stretchr/testify v1.10.0
+ github.com/zitadel/logging v0.6.1
github.com/zitadel/schema v1.3.0
- go.opentelemetry.io/otel v1.26.0
- golang.org/x/oauth2 v0.20.0
- golang.org/x/text v0.15.0
+ go.opentelemetry.io/otel v1.29.0
+ golang.org/x/oauth2 v0.26.0
+ golang.org/x/text v0.22.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
- github.com/go-logr/logr v1.4.1 // indirect
+ github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
- go.opentelemetry.io/otel/metric v1.26.0 // indirect
- go.opentelemetry.io/otel/trace v1.26.0 // indirect
- golang.org/x/crypto v0.22.0 // indirect
- golang.org/x/net v0.23.0 // indirect
- golang.org/x/sys v0.19.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.31.0 // indirect
+ golang.org/x/net v0.33.0 // indirect
+ golang.org/x/sys v0.28.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index fa16701..41fd786 100644
--- a/go.sum
+++ b/go.sum
@@ -1,15 +1,15 @@
-github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
-github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
+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.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
-github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
-github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
-github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
+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.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E=
+github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
-github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
-github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+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=
@@ -29,8 +29,8 @@ 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.7 h1:At0OhGCFGPXyjPYAsCchoBUhE099pcBXmsb4iZqROIc=
-github.com/jeremija/gosubmit v0.2.7/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI=
+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/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
@@ -41,40 +41,40 @@ github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/
github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0=
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.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po=
-github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
+github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
+github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
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/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
-github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
-github.com/zitadel/logging v0.6.0 h1:t5Nnt//r+m2ZhhoTmoPX+c96pbMarqJvW1Vq6xFTank=
-github.com/zitadel/logging v0.6.0/go.mod h1:Y4CyAXHpl3Mig6JOszcV5Rqqsojj+3n7y2F591Mp/ow=
+github.com/zitadel/logging v0.6.1 h1:Vyzk1rl9Kq9RCevcpX6ujUaTYFX43aa4LkvV1TvUk+Y=
+github.com/zitadel/logging v0.6.1/go.mod h1:Y4CyAXHpl3Mig6JOszcV5Rqqsojj+3n7y2F591Mp/ow=
github.com/zitadel/schema v1.3.0 h1:kQ9W9tvIwZICCKWcMvCEweXET1OcOyGEuFbHs4o5kg0=
github.com/zitadel/schema v1.3.0/go.mod h1:NptN6mkBDFvERUCvZHlvWmmME+gmZ44xzwRXwhzsbtc=
-go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs=
-go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4=
-go.opentelemetry.io/otel/metric v1.26.0 h1:7S39CLuY5Jgg9CrnA9HHiEjGMF/X2VHvoXGgSllRz30=
-go.opentelemetry.io/otel/metric v1.26.0/go.mod h1:SY+rHOI4cEawI9a7N1A4nIg/nTQXe1ccCNWYOJUrpX4=
-go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA=
-go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0=
+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=
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.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
-golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
+golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
+golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
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-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
-golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
-golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
+golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
+golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
-golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo=
-golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
+golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE=
+golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
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/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -83,13 +83,13 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
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-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
-golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
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.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
-golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
+golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
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=
diff --git a/pkg/client/client.go b/pkg/client/client.go
index e17c70a..56417b5 100644
--- a/pkg/client/client.go
+++ b/pkg/client/client.go
@@ -12,11 +12,12 @@ import (
"github.com/go-jose/go-jose/v4"
"github.com/zitadel/logging"
+ "go.opentelemetry.io/otel"
+ "golang.org/x/oauth2"
+
"github.com/zitadel/oidc/v3/pkg/crypto"
httphelper "github.com/zitadel/oidc/v3/pkg/http"
"github.com/zitadel/oidc/v3/pkg/oidc"
- "go.opentelemetry.io/otel"
- "golang.org/x/oauth2"
)
var (
@@ -41,7 +42,7 @@ func Discover(ctx context.Context, issuer string, httpClient *http.Client, wellK
discoveryConfig := new(oidc.DiscoveryConfiguration)
err = httphelper.HttpRequest(httpClient, req, &discoveryConfig)
if err != nil {
- return nil, err
+ return nil, errors.Join(oidc.ErrDiscoveryFailed, err)
}
if logger, ok := logging.FromContext(ctx); ok {
logger.Debug("discover", "config", discoveryConfig)
@@ -196,12 +197,12 @@ func CallTokenExchangeEndpoint(ctx context.Context, request any, authFn any, cal
}
func NewSignerFromPrivateKeyByte(key []byte, keyID string) (jose.Signer, error) {
- privateKey, err := crypto.BytesToPrivateKey(key)
+ privateKey, algorithm, err := crypto.BytesToPrivateKey(key)
if err != nil {
return nil, err
}
signingKey := jose.SigningKey{
- Algorithm: jose.RS256,
+ Algorithm: algorithm,
Key: &jose.JSONWebKey{Key: privateKey, KeyID: keyID},
}
return jose.NewSigner(signingKey, &jose.SignerOptions{})
diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go
index e06c825..1046941 100644
--- a/pkg/client/client_test.go
+++ b/pkg/client/client_test.go
@@ -7,6 +7,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "github.com/zitadel/oidc/v3/pkg/oidc"
)
func TestDiscover(t *testing.T) {
@@ -22,7 +23,7 @@ func TestDiscover(t *testing.T) {
name string
args args
wantFields *wantFields
- wantErr bool
+ wantErr error
}{
{
name: "spotify", // https://github.com/zitadel/oidc/issues/406
@@ -32,17 +33,20 @@ func TestDiscover(t *testing.T) {
wantFields: &wantFields{
UILocalesSupported: true,
},
- wantErr: false,
+ 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...)
- if tt.wantErr {
- assert.Error(t, err)
- return
- }
- require.NoError(t, err)
+ require.ErrorIs(t, err, tt.wantErr)
if tt.wantFields == nil {
return
}
diff --git a/pkg/client/rp/relying_party.go b/pkg/client/rp/relying_party.go
index 029a897..e6fa078 100644
--- a/pkg/client/rp/relying_party.go
+++ b/pkg/client/rp/relying_party.go
@@ -541,7 +541,7 @@ func CodeExchangeHandler[C oidc.IDClaims](callback CodeExchangeCallback[C], rp R
rp.CookieHandler().DeleteCookie(w, pkceCode)
}
if rp.Signer() != nil {
- assertion, err := client.SignedJWTProfileAssertion(rp.OAuthConfig().ClientID, []string{rp.Issuer()}, time.Hour, rp.Signer())
+ assertion, err := client.SignedJWTProfileAssertion(rp.OAuthConfig().ClientID, []string{rp.Issuer(), rp.OAuthConfig().Endpoint.TokenURL}, time.Hour, rp.Signer())
if err != nil {
unauthorizedError(w, r, "failed to build assertion: "+err.Error(), state, rp)
return
diff --git a/pkg/crypto/hash.go b/pkg/crypto/hash.go
index ab9f8c1..14acdee 100644
--- a/pkg/crypto/hash.go
+++ b/pkg/crypto/hash.go
@@ -21,6 +21,14 @@ 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)
}
diff --git a/pkg/crypto/key.go b/pkg/crypto/key.go
index 79e2046..12bca28 100644
--- a/pkg/crypto/key.go
+++ b/pkg/crypto/key.go
@@ -1,22 +1,45 @@
package crypto
import (
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/ed25519"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
+
+ "github.com/go-jose/go-jose/v4"
)
-func BytesToPrivateKey(b []byte) (*rsa.PrivateKey, error) {
+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, errors.New("PEM decode failed")
+ return nil, "", ErrPEMDecode
}
- key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
+ privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
+ if err == nil {
+ return privateKey, jose.RS256, nil
+ }
+ key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
- return nil, err
+ 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 key, nil
}
diff --git a/pkg/crypto/key_test.go b/pkg/crypto/key_test.go
index 23ebdc0..8ed5cb5 100644
--- a/pkg/crypto/key_test.go
+++ b/pkg/crypto/key_test.go
@@ -1,21 +1,64 @@
package crypto_test
import (
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/ed25519"
+ "crypto/rsa"
"testing"
+ "github.com/go-jose/go-jose/v4"
"github.com/stretchr/testify/assert"
- "github.com/zitadel/oidc/v3/pkg/crypto"
+ zcrypto "github.com/zitadel/oidc/v3/pkg/crypto"
)
-func TestBytesToPrivateKey(tt *testing.T) {
- tt.Run("PEMDecodeError", func(t *testing.T) {
- _, err := crypto.BytesToPrivateKey([]byte("The non-PEM sequence"))
- assert.EqualError(t, err, "PEM decode failed")
- })
-
- tt.Run("InvalidKeyFormat", func(t *testing.T) {
- _, err := crypto.BytesToPrivateKey([]byte(`-----BEGIN PRIVATE KEY-----
+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
@@ -42,21 +85,50 @@ srJnjF0H8oKmAY6hw+1Tm/n/b08p+RyL48TgVSE2vhUCgYA3BWpkD4PlCcn/FZsq
OrLFyFXI6jIaxskFtsRW1IxxIlAdZmxfB26P/2gx6VjLdxJI/RRPkJyEN2dP7CbR
BDjb565dy1O9D6+UrY70Iuwjz+OcALRBBGTaiF2pLn6IhSzNI2sy/tXX8q8dBlg9
OFCrqT/emes3KytTPfa5NZtYeQ==
------END PRIVATE KEY-----`))
- assert.EqualError(t, err, "x509: failed to parse private key (use ParsePKCS8PrivateKey instead for this key format)")
- })
+-----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)
+ })
- tt.Run("Ok", func(t *testing.T) {
- key, err := crypto.BytesToPrivateKey([]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-----`))
- assert.NoError(t, err)
- assert.NotNil(t, key)
- })
+ }
}
diff --git a/pkg/oidc/discovery.go b/pkg/oidc/discovery.go
index 14fce5e..62288d1 100644
--- a/pkg/oidc/discovery.go
+++ b/pkg/oidc/discovery.go
@@ -145,6 +145,14 @@ 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
diff --git a/pkg/oidc/error.go b/pkg/oidc/error.go
index 2f0572d..1100f73 100644
--- a/pkg/oidc/error.go
+++ b/pkg/oidc/error.go
@@ -1,6 +1,7 @@
package oidc
import (
+ "encoding/json"
"errors"
"fmt"
"log/slog"
@@ -133,6 +134,24 @@ type Error struct {
Description string `json:"error_description,omitempty" schema:"error_description,omitempty"`
State string `json:"state,omitempty" schema:"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"`
+ Parent string `json:"parent,omitempty"`
+ }{
+ Error: e.ErrorType,
+ ErrorDescription: e.Description,
+ State: e.State,
+ }
+ if e.returnParent {
+ m.Parent = e.Parent.Error()
+ }
+ return json.Marshal(m)
}
func (e *Error) Error() string {
@@ -165,6 +184,18 @@ 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 {
e.Description = fmt.Sprintf(desc, args...)
return e
diff --git a/pkg/oidc/error_test.go b/pkg/oidc/error_test.go
index 2eeb4e6..40d30b1 100644
--- a/pkg/oidc/error_test.go
+++ b/pkg/oidc/error_test.go
@@ -1,11 +1,14 @@
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) {
@@ -151,3 +154,39 @@ func TestError_LogValue(t *testing.T) {
})
}
}
+
+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))
+ })
+ }
+}
diff --git a/pkg/oidc/keyset.go b/pkg/oidc/keyset.go
index 833878d..a8b89b0 100644
--- a/pkg/oidc/keyset.go
+++ b/pkg/oidc/keyset.go
@@ -6,6 +6,7 @@ import (
"crypto/ed25519"
"crypto/rsa"
"errors"
+ "strings"
jose "github.com/go-jose/go-jose/v4"
)
@@ -92,17 +93,17 @@ func FindMatchingKey(keyID, use, expectedAlg string, keys ...jose.JSONWebKey) (k
}
func algToKeyType(key any, alg string) bool {
- switch alg[0] {
- case 'R', 'P':
+ if strings.HasPrefix(alg, "RS") || strings.HasPrefix(alg, "PS") {
_, ok := key.(*rsa.PublicKey)
return ok
- case 'E':
+ }
+ if strings.HasPrefix(alg, "ES") {
_, ok := key.(*ecdsa.PublicKey)
return ok
- case 'O':
- _, ok := key.(*ed25519.PublicKey)
- return ok
- default:
- return false
}
+ if alg == string(jose.EdDSA) {
+ _, ok := key.(ed25519.PublicKey)
+ return ok
+ }
+ return false
}
diff --git a/pkg/oidc/token.go b/pkg/oidc/token.go
index 8d2880c..d2b6f6d 100644
--- a/pkg/oidc/token.go
+++ b/pkg/oidc/token.go
@@ -9,6 +9,7 @@ import (
"golang.org/x/oauth2"
"github.com/muhlemmer/gu"
+
"github.com/zitadel/oidc/v3/pkg/crypto"
)
@@ -116,6 +117,7 @@ func NewAccessTokenClaims(issuer, subject string, audience []string, expiration
Expiration: FromTime(expiration),
IssuedAt: FromTime(now),
NotBefore: FromTime(now),
+ ClientID: clientID,
JWTID: jwtid,
},
}
@@ -228,12 +230,13 @@ func (c *ActorClaims) UnmarshalJSON(data []byte) error {
}
type AccessTokenResponse struct {
- AccessToken string `json:"access_token,omitempty" schema:"access_token,omitempty"`
- TokenType string `json:"token_type,omitempty" schema:"token_type,omitempty"`
- RefreshToken string `json:"refresh_token,omitempty" schema:"refresh_token,omitempty"`
- ExpiresIn uint64 `json:"expires_in,omitempty" schema:"expires_in,omitempty"`
- IDToken string `json:"id_token,omitempty" schema:"id_token,omitempty"`
- State string `json:"state,omitempty" schema:"state,omitempty"`
+ AccessToken string `json:"access_token,omitempty" schema:"access_token,omitempty"`
+ TokenType string `json:"token_type,omitempty" schema:"token_type,omitempty"`
+ RefreshToken string `json:"refresh_token,omitempty" schema:"refresh_token,omitempty"`
+ ExpiresIn uint64 `json:"expires_in,omitempty" schema:"expires_in,omitempty"`
+ IDToken string `json:"id_token,omitempty" schema:"id_token,omitempty"`
+ State string `json:"state,omitempty" schema:"state,omitempty"`
+ Scope SpaceDelimitedArray `json:"scope,omitempty" schema:"scope,omitempty"`
}
type JWTProfileAssertionClaims struct {
@@ -344,12 +347,12 @@ func AppendClientIDToAudience(clientID string, audience []string) []string {
}
func GenerateJWTProfileToken(assertion *JWTProfileAssertionClaims) (string, error) {
- privateKey, err := crypto.BytesToPrivateKey(assertion.PrivateKey)
+ privateKey, algorithm, err := crypto.BytesToPrivateKey(assertion.PrivateKey)
if err != nil {
return "", err
}
key := jose.SigningKey{
- Algorithm: jose.RS256,
+ Algorithm: algorithm,
Key: &jose.JSONWebKey{Key: privateKey, KeyID: assertion.PrivateKeyID},
}
signer, err := jose.NewSigner(key, &jose.SignerOptions{})
@@ -380,3 +383,40 @@ type TokenExchangeResponse struct {
// if the requested_token_type was Access Token and scope contained openid.
IDToken string `json:"id_token,omitempty"`
}
+
+type LogoutTokenClaims struct {
+ Issuer string `json:"iss,omitempty"`
+ Subject string `json:"sub,omitempty"`
+ Audience Audience `json:"aud,omitempty"`
+ IssuedAt Time `json:"iat,omitempty"`
+ Expiration Time `json:"exp,omitempty"`
+ JWTID string `json:"jti,omitempty"`
+ Events map[string]any `json:"events,omitempty"`
+ SessionID string `json:"sid,omitempty"`
+ Claims map[string]any `json:"-"`
+}
+
+type ltcAlias LogoutTokenClaims
+
+func (i *LogoutTokenClaims) MarshalJSON() ([]byte, error) {
+ return mergeAndMarshalClaims((*ltcAlias)(i), i.Claims)
+}
+
+func (i *LogoutTokenClaims) UnmarshalJSON(data []byte) error {
+ return unmarshalJSONMulti(data, (*ltcAlias)(i), &i.Claims)
+}
+
+func NewLogoutTokenClaims(issuer, subject string, audience Audience, expiration time.Time, jwtID, sessionID string, skew time.Duration) *LogoutTokenClaims {
+ return &LogoutTokenClaims{
+ Issuer: issuer,
+ Subject: subject,
+ Audience: audience,
+ IssuedAt: FromTime(time.Now().Add(-skew)),
+ Expiration: FromTime(expiration),
+ JWTID: jwtID,
+ Events: map[string]any{
+ "http://schemas.openid.net/event/backchannel-logout": struct{}{},
+ },
+ SessionID: sessionID,
+ }
+}
diff --git a/pkg/oidc/token_request.go b/pkg/oidc/token_request.go
index f3b2ec4..dadb205 100644
--- a/pkg/oidc/token_request.go
+++ b/pkg/oidc/token_request.go
@@ -72,10 +72,10 @@ type AccessTokenRequest struct {
Code string `schema:"code"`
RedirectURI string `schema:"redirect_uri"`
ClientID string `schema:"client_id"`
- ClientSecret string `schema:"client_secret"`
- CodeVerifier string `schema:"code_verifier"`
- ClientAssertion string `schema:"client_assertion"`
- ClientAssertionType string `schema:"client_assertion_type"`
+ ClientSecret string `schema:"client_secret,omitempty"`
+ CodeVerifier string `schema:"code_verifier,omitempty"`
+ ClientAssertion string `schema:"client_assertion,omitempty"`
+ ClientAssertionType string `schema:"client_assertion_type,omitempty"`
}
func (a *AccessTokenRequest) GrantType() GrantType {
diff --git a/pkg/oidc/token_test.go b/pkg/oidc/token_test.go
index ccc3467..621cdbc 100644
--- a/pkg/oidc/token_test.go
+++ b/pkg/oidc/token_test.go
@@ -145,6 +145,7 @@ func TestNewAccessTokenClaims(t *testing.T) {
Subject: "hello@me.com",
Audience: Audience{"foo"},
Expiration: 12345,
+ ClientID: "foo",
JWTID: "900",
},
}
@@ -241,3 +242,39 @@ func TestIDTokenClaims_GetUserInfo(t *testing.T) {
got := idTokenData.GetUserInfo()
assert.Equal(t, want, got)
}
+
+func TestNewLogoutTokenClaims(t *testing.T) {
+ want := &LogoutTokenClaims{
+ Issuer: "zitadel",
+ Subject: "hello@me.com",
+ Audience: Audience{"foo", "just@me.com"},
+ Expiration: 12345,
+ JWTID: "jwtID",
+ Events: map[string]any{
+ "http://schemas.openid.net/event/backchannel-logout": struct{}{},
+ },
+ SessionID: "sessionID",
+ Claims: nil,
+ }
+
+ got := NewLogoutTokenClaims(
+ want.Issuer,
+ want.Subject,
+ want.Audience,
+ want.Expiration.AsTime(),
+ want.JWTID,
+ want.SessionID,
+ 1*time.Second,
+ )
+
+ // test if the dynamic timestamp is around now,
+ // allowing for a delta of 1, just in case we flip on
+ // either side of a second boundry.
+ nowMinusSkew := NowTime() - 1
+ assert.InDelta(t, int64(nowMinusSkew), int64(got.IssuedAt), 1)
+
+ // Make equal not fail on dynamic timestamp
+ got.IssuedAt = 0
+
+ assert.Equal(t, want, got)
+}
diff --git a/pkg/oidc/types.go b/pkg/oidc/types.go
index e7292e6..7063426 100644
--- a/pkg/oidc/types.go
+++ b/pkg/oidc/types.go
@@ -3,7 +3,6 @@ package oidc
import (
"database/sql/driver"
"encoding/json"
- "errors"
"fmt"
"reflect"
"strings"
@@ -78,22 +77,16 @@ func (l *Locale) MarshalJSON() ([]byte, error) {
}
// UnmarshalJSON implements json.Unmarshaler.
-// When [language.ValueError] is encountered, the containing tag will be set
+// All unmarshal errors for are ignored.
+// When an error is encountered, the containing tag will be set
// to an empty value (language "und") and no error will be returned.
// This state can be checked with the `l.Tag().IsRoot()` method.
func (l *Locale) UnmarshalJSON(data []byte) error {
err := json.Unmarshal(data, &l.tag)
- if err == nil {
- return nil
- }
-
- // catch "well-formed but unknown" errors
- var target language.ValueError
- if errors.As(err, &target) {
+ if err != nil {
l.tag = language.Tag{}
- return nil
}
- return err
+ return nil
}
type Locales []language.Tag
diff --git a/pkg/oidc/types_test.go b/pkg/oidc/types_test.go
index df93a73..c7ce0ee 100644
--- a/pkg/oidc/types_test.go
+++ b/pkg/oidc/types_test.go
@@ -232,9 +232,11 @@ func TestLocale_UnmarshalJSON(t *testing.T) {
},
},
{
- name: "bad form, error",
- input: `{"locale": "g!!!!!"}`,
- wantErr: true,
+ name: "bad form, error",
+ input: `{"locale": "g!!!!!"}`,
+ want: dst{
+ Locale: &Locale{},
+ },
},
}
diff --git a/pkg/oidc/verifier.go b/pkg/oidc/verifier.go
index cb66676..d5e0213 100644
--- a/pkg/oidc/verifier.go
+++ b/pkg/oidc/verifier.go
@@ -7,12 +7,11 @@ import (
"encoding/json"
"errors"
"fmt"
+ "slices"
"strings"
"time"
jose "github.com/go-jose/go-jose/v4"
-
- str "github.com/zitadel/oidc/v3/pkg/strings"
)
type Claims interface {
@@ -41,6 +40,7 @@ type IDClaims interface {
var (
ErrParse = errors.New("parsing of request failed")
ErrIssuerInvalid = errors.New("issuer does not match")
+ ErrDiscoveryFailed = errors.New("OpenID Provider Configuration Discovery has failed")
ErrSubjectMissing = errors.New("subject missing")
ErrAudience = errors.New("audience is not valid")
ErrAzpMissing = errors.New("authorized party is not set. If Token is valid for multiple audiences, azp must not be empty")
@@ -83,7 +83,7 @@ type ACRVerifier func(string) error
// if none of the provided values matches the acr claim
func DefaultACRVerifier(possibleValues []string) ACRVerifier {
return func(acr string) error {
- if !str.Contains(possibleValues, acr) {
+ if !slices.Contains(possibleValues, acr) {
return fmt.Errorf("expected one of: %v, got: %q", possibleValues, acr)
}
return nil
@@ -122,7 +122,7 @@ func CheckIssuer(claims Claims, issuer string) error {
}
func CheckAudience(claims Claims, clientID string) error {
- if !str.Contains(claims.GetAudience(), clientID) {
+ if !slices.Contains(claims.GetAudience(), clientID) {
return fmt.Errorf("%w: Audience must contain client_id %q", ErrAudience, clientID)
}
diff --git a/pkg/op/auth_request.go b/pkg/op/auth_request.go
index 923b9a7..d6db62b 100644
--- a/pkg/op/auth_request.go
+++ b/pkg/op/auth_request.go
@@ -11,13 +11,13 @@ import (
"net"
"net/http"
"net/url"
+ "slices"
"strings"
"time"
"github.com/bmatcuk/doublestar/v4"
httphelper "github.com/zitadel/oidc/v3/pkg/http"
"github.com/zitadel/oidc/v3/pkg/oidc"
- str "github.com/zitadel/oidc/v3/pkg/strings"
)
type AuthRequest interface {
@@ -82,19 +82,27 @@ func Authorize(w http.ResponseWriter, r *http.Request, authorizer Authorizer) {
if authReq.RequestParam != "" && authorizer.RequestObjectSupported() {
err = ParseRequestObject(ctx, authReq, authorizer.Storage(), IssuerFromContext(ctx))
if err != nil {
- AuthRequestError(w, r, authReq, err, authorizer)
+ AuthRequestError(w, r, nil, err, authorizer)
return
}
}
if authReq.ClientID == "" {
- AuthRequestError(w, r, authReq, fmt.Errorf("auth request is missing client_id"), authorizer)
+ AuthRequestError(w, r, nil, fmt.Errorf("auth request is missing client_id"), authorizer)
return
}
if authReq.RedirectURI == "" {
- AuthRequestError(w, r, authReq, fmt.Errorf("auth request is missing redirect_uri"), authorizer)
+ AuthRequestError(w, r, nil, fmt.Errorf("auth request is missing redirect_uri"), authorizer)
return
}
- validation := ValidateAuthRequest
+
+ var client Client
+ validation := func(ctx context.Context, authReq *oidc.AuthRequest, storage Storage, verifier *IDTokenHintVerifier) (sub string, err error) {
+ client, err = authorizer.Storage().GetClientByClientID(ctx, authReq.ClientID)
+ if err != nil {
+ return "", oidc.ErrInvalidRequestRedirectURI().WithDescription("unable to retrieve client by id").WithParent(err)
+ }
+ return ValidateAuthRequestClient(ctx, authReq, client, verifier)
+ }
if validater, ok := authorizer.(AuthorizeValidator); ok {
validation = validater.ValidateAuthRequest
}
@@ -112,11 +120,6 @@ func Authorize(w http.ResponseWriter, r *http.Request, authorizer Authorizer) {
AuthRequestError(w, r, authReq, oidc.DefaultToServerError(err, "unable to save auth request"), authorizer)
return
}
- client, err := authorizer.Storage().GetClientByClientID(ctx, req.GetClientID())
- if err != nil {
- AuthRequestError(w, r, req, oidc.DefaultToServerError(err, "unable to retrieve client by id"), authorizer)
- return
- }
RedirectToLogin(req.GetID(), client, w, r)
}
@@ -152,7 +155,7 @@ func ParseRequestObject(ctx context.Context, authReq *oidc.AuthRequest, storage
if requestObject.Issuer != requestObject.ClientID {
return oidc.ErrInvalidRequest().WithDescription("missing or wrong issuer in request")
}
- if !str.Contains(requestObject.Audience, issuer) {
+ if !slices.Contains(requestObject.Audience, issuer) {
return oidc.ErrInvalidRequest().WithDescription("issuer missing in audience")
}
keySet := &jwtProfileKeySet{storage: storage, clientID: requestObject.Issuer}
@@ -166,7 +169,7 @@ func ParseRequestObject(ctx context.Context, authReq *oidc.AuthRequest, storage
// CopyRequestObjectToAuthRequest overwrites present values from the Request Object into the auth request
// and clears the `RequestParam` of the auth request
func CopyRequestObjectToAuthRequest(authReq *oidc.AuthRequest, requestObject *oidc.RequestObject) {
- if str.Contains(authReq.Scopes, oidc.ScopeOpenID) && len(requestObject.Scopes) > 0 {
+ if slices.Contains(authReq.Scopes, oidc.ScopeOpenID) && len(requestObject.Scopes) > 0 {
authReq.Scopes = requestObject.Scopes
}
if requestObject.RedirectURI != "" {
@@ -211,26 +214,37 @@ func CopyRequestObjectToAuthRequest(authReq *oidc.AuthRequest, requestObject *oi
authReq.RequestParam = ""
}
-// ValidateAuthRequest validates the authorize parameters and returns the userID of the id_token_hint if passed
+// ValidateAuthRequest validates the authorize parameters and returns the userID of the id_token_hint if passed.
+//
+// Deprecated: Use [ValidateAuthRequestClient] to prevent querying for the Client twice.
func ValidateAuthRequest(ctx context.Context, authReq *oidc.AuthRequest, storage Storage, verifier *IDTokenHintVerifier) (sub string, err error) {
ctx, span := tracer.Start(ctx, "ValidateAuthRequest")
defer span.End()
+ client, err := storage.GetClientByClientID(ctx, authReq.ClientID)
+ if err != nil {
+ return "", oidc.ErrInvalidRequestRedirectURI().WithDescription("unable to retrieve client by id").WithParent(err)
+ }
+ return ValidateAuthRequestClient(ctx, authReq, client, verifier)
+}
+
+// ValidateAuthRequestClient validates the Auth request against the passed client.
+// If id_token_hint is part of the request, the subject of the token is returned.
+func ValidateAuthRequestClient(ctx context.Context, authReq *oidc.AuthRequest, client Client, verifier *IDTokenHintVerifier) (sub string, err error) {
+ ctx, span := tracer.Start(ctx, "ValidateAuthRequestClient")
+ defer span.End()
+
+ if err := ValidateAuthReqRedirectURI(client, authReq.RedirectURI, authReq.ResponseType); err != nil {
+ return "", err
+ }
authReq.MaxAge, err = ValidateAuthReqPrompt(authReq.Prompt, authReq.MaxAge)
if err != nil {
return "", err
}
- client, err := storage.GetClientByClientID(ctx, authReq.ClientID)
- if err != nil {
- return "", oidc.DefaultToServerError(err, "unable to retrieve client by id")
- }
authReq.Scopes, err = ValidateAuthReqScopes(client, authReq.Scopes)
if err != nil {
return "", err
}
- if err := ValidateAuthReqRedirectURI(client, authReq.RedirectURI, authReq.ResponseType); err != nil {
- return "", err
- }
if err := ValidateAuthReqResponseType(client, authReq.ResponseType); err != nil {
return "", err
}
@@ -250,44 +264,30 @@ func ValidateAuthReqPrompt(prompts []string, maxAge *uint) (_ *uint, err error)
return maxAge, nil
}
-// ValidateAuthReqScopes validates the passed scopes
+// ValidateAuthReqScopes validates the passed scopes and deletes any unsupported scopes.
+// An error is returned if scopes is empty.
func ValidateAuthReqScopes(client Client, scopes []string) ([]string, error) {
if len(scopes) == 0 {
return nil, oidc.ErrInvalidRequest().
WithDescription("The scope of your request is missing. Please ensure some scopes are requested. " +
"If you have any questions, you may contact the administrator of the application.")
}
- openID := false
- for i := len(scopes) - 1; i >= 0; i-- {
- scope := scopes[i]
- if scope == oidc.ScopeOpenID {
- openID = true
- continue
- }
- if !(scope == oidc.ScopeProfile ||
+ scopes = slices.DeleteFunc(scopes, func(scope string) bool {
+ return !(scope == oidc.ScopeOpenID ||
+ scope == oidc.ScopeProfile ||
scope == oidc.ScopeEmail ||
scope == oidc.ScopePhone ||
scope == oidc.ScopeAddress ||
scope == oidc.ScopeOfflineAccess) &&
- !client.IsScopeAllowed(scope) {
- scopes[i] = scopes[len(scopes)-1]
- scopes[len(scopes)-1] = ""
- scopes = scopes[:len(scopes)-1]
- }
- }
- if !openID {
- return nil, oidc.ErrInvalidScope().WithDescription("The scope openid is missing in your request. " +
- "Please ensure the scope openid is added to the request. " +
- "If you have any questions, you may contact the administrator of the application.")
- }
-
+ !client.IsScopeAllowed(scope)
+ })
return scopes, nil
}
// checkURIAgainstRedirects just checks aginst the valid redirect URIs and ignores
// other factors.
func checkURIAgainstRedirects(client Client, uri string) error {
- if str.Contains(client.RedirectURIs(), uri) {
+ if slices.Contains(client.RedirectURIs(), uri) {
return nil
}
if globClient, ok := client.(HasRedirectGlobs); ok {
@@ -312,12 +312,12 @@ func ValidateAuthReqRedirectURI(client Client, uri string, responseType oidc.Res
return oidc.ErrInvalidRequestRedirectURI().WithDescription("The redirect_uri is missing in the request. " +
"Please ensure it is added to the request. If you have any questions, you may contact the administrator of the application.")
}
- if strings.HasPrefix(uri, "https://") {
- return checkURIAgainstRedirects(client, uri)
- }
if client.ApplicationType() == ApplicationTypeNative {
return validateAuthReqRedirectURINative(client, uri)
}
+ if strings.HasPrefix(uri, "https://") {
+ return checkURIAgainstRedirects(client, uri)
+ }
if err := checkURIAgainstRedirects(client, uri); err != nil {
return err
}
@@ -338,12 +338,15 @@ func ValidateAuthReqRedirectURI(client Client, uri string, responseType oidc.Res
// ValidateAuthReqRedirectURINative validates the passed redirect_uri and response_type to the registered uris and client type
func validateAuthReqRedirectURINative(client Client, uri string) error {
parsedURL, isLoopback := HTTPLoopbackOrLocalhost(uri)
- isCustomSchema := !strings.HasPrefix(uri, "http://")
+ isCustomSchema := !(strings.HasPrefix(uri, "http://") || strings.HasPrefix(uri, "https://"))
if err := checkURIAgainstRedirects(client, uri); err == nil {
if client.DevMode() {
return nil
}
- // The RedirectURIs are only valid for native clients when localhost or non-"http://"
+ if !isLoopback && strings.HasPrefix(uri, "https://") {
+ return nil
+ }
+ // The RedirectURIs are only valid for native clients when localhost or non-"http://" and "https://"
if isLoopback || isCustomSchema {
return nil
}
@@ -373,11 +376,11 @@ func HTTPLoopbackOrLocalhost(rawURL string) (*url.URL, bool) {
if err != nil {
return nil, false
}
- if parsedURL.Scheme != "http" {
- return nil, false
+ if parsedURL.Scheme == "http" || parsedURL.Scheme == "https" {
+ hostName := parsedURL.Hostname()
+ return parsedURL, hostName == "localhost" || net.ParseIP(hostName).IsLoopback()
}
- hostName := parsedURL.Hostname()
- return parsedURL, hostName == "localhost" || net.ParseIP(hostName).IsLoopback()
+ return nil, false
}
// ValidateAuthReqResponseType validates the passed response_type to the registered response types
diff --git a/pkg/op/auth_request_test.go b/pkg/op/auth_request_test.go
index 45627a5..765e602 100644
--- a/pkg/op/auth_request_test.go
+++ b/pkg/op/auth_request_test.go
@@ -137,11 +137,6 @@ func TestValidateAuthRequest(t *testing.T) {
args{&oidc.AuthRequest{}, mock.NewMockStorageExpectValidClientID(t), nil},
oidc.ErrInvalidRequest(),
},
- {
- "scope openid missing fails",
- args{&oidc.AuthRequest{Scopes: []string{"profile"}}, mock.NewMockStorageExpectValidClientID(t), nil},
- oidc.ErrInvalidScope(),
- },
{
"response_type missing fails",
args{&oidc.AuthRequest{Scopes: []string{"openid"}}, mock.NewMockStorageExpectValidClientID(t), nil},
@@ -287,16 +282,6 @@ func TestValidateAuthReqScopes(t *testing.T) {
err: true,
},
},
- {
- "scope openid missing fails",
- args{
- mock.NewClientExpectAny(t, op.ApplicationTypeWeb),
- []string{"email"},
- },
- res{
- err: true,
- },
- },
{
"scope ok",
args{
@@ -448,6 +433,24 @@ func TestValidateAuthReqRedirectURI(t *testing.T) {
},
false,
},
+ {
+ "code flow registered https loopback v4 native ok",
+ args{
+ "https://127.0.0.1:4200/callback",
+ mock.NewClientWithConfig(t, []string{"https://127.0.0.1/callback"}, op.ApplicationTypeNative, nil, false),
+ oidc.ResponseTypeCode,
+ },
+ false,
+ },
+ {
+ "code flow registered https loopback v6 native ok",
+ args{
+ "https://[::1]:4200/callback",
+ mock.NewClientWithConfig(t, []string{"https://[::1]/callback"}, op.ApplicationTypeNative, nil, false),
+ oidc.ResponseTypeCode,
+ },
+ false,
+ },
{
"code flow unregistered http native fails",
args{
diff --git a/pkg/op/config.go b/pkg/op/config.go
index 9fec7cc..2fcede0 100644
--- a/pkg/op/config.go
+++ b/pkg/op/config.go
@@ -49,6 +49,9 @@ type Configuration interface {
SupportedUILocales() []language.Tag
DeviceAuthorization() DeviceAuthorizationConfig
+
+ BackChannelLogoutSupported() bool
+ BackChannelLogoutSessionSupported() bool
}
type IssuerFromRequest func(r *http.Request) string
diff --git a/pkg/op/device.go b/pkg/op/device.go
index 11638b0..8a0e174 100644
--- a/pkg/op/device.go
+++ b/pkg/op/device.go
@@ -9,12 +9,12 @@ import (
"math/big"
"net/http"
"net/url"
+ "slices"
"strings"
"time"
httphelper "github.com/zitadel/oidc/v3/pkg/http"
"github.com/zitadel/oidc/v3/pkg/oidc"
- strs "github.com/zitadel/oidc/v3/pkg/strings"
)
type DeviceAuthorizationConfig struct {
@@ -276,7 +276,7 @@ func (r *DeviceAuthorizationState) GetAMR() []string {
}
func (r *DeviceAuthorizationState) GetAudience() []string {
- if !strs.Contains(r.Audience, r.ClientID) {
+ if !slices.Contains(r.Audience, r.ClientID) {
r.Audience = append(r.Audience, r.ClientID)
}
return r.Audience
@@ -344,10 +344,11 @@ func CreateDeviceTokenResponse(ctx context.Context, tokenRequest TokenRequest, c
RefreshToken: refreshToken,
TokenType: oidc.BearerToken,
ExpiresIn: uint64(validity.Seconds()),
+ Scope: tokenRequest.GetScopes(),
}
// TODO(v4): remove type assertion
- if idTokenRequest, ok := tokenRequest.(IDTokenRequest); ok && strs.Contains(tokenRequest.GetScopes(), oidc.ScopeOpenID) {
+ if idTokenRequest, ok := tokenRequest.(IDTokenRequest); ok && slices.Contains(tokenRequest.GetScopes(), oidc.ScopeOpenID) {
response.IDToken, err = CreateIDToken(ctx, IssuerFromContext(ctx), idTokenRequest, client.IDTokenLifetime(), accessToken, "", creator.Storage(), client)
if err != nil {
return nil, err
diff --git a/pkg/op/discovery.go b/pkg/op/discovery.go
index cd08580..e30a5a4 100644
--- a/pkg/op/discovery.go
+++ b/pkg/op/discovery.go
@@ -61,6 +61,8 @@ func CreateDiscoveryConfig(ctx context.Context, config Configuration, storage Di
CodeChallengeMethodsSupported: CodeChallengeMethods(config),
UILocalesSupported: config.SupportedUILocales(),
RequestParameterSupported: config.RequestObjectSupported(),
+ BackChannelLogoutSupported: config.BackChannelLogoutSupported(),
+ BackChannelLogoutSessionSupported: config.BackChannelLogoutSessionSupported(),
}
}
@@ -92,11 +94,17 @@ func createDiscoveryConfigV2(ctx context.Context, config Configuration, storage
CodeChallengeMethodsSupported: CodeChallengeMethods(config),
UILocalesSupported: config.SupportedUILocales(),
RequestParameterSupported: config.RequestObjectSupported(),
+ BackChannelLogoutSupported: config.BackChannelLogoutSupported(),
+ BackChannelLogoutSessionSupported: config.BackChannelLogoutSessionSupported(),
}
}
func Scopes(c Configuration) []string {
- return DefaultSupportedScopes // TODO: config
+ provider, ok := c.(*Provider)
+ if ok && provider.config.SupportedScopes != nil {
+ return provider.config.SupportedScopes
+ }
+ return DefaultSupportedScopes
}
func ResponseTypes(c Configuration) []string {
@@ -131,7 +139,7 @@ func GrantTypes(c Configuration) []oidc.GrantType {
}
func SubjectTypes(c Configuration) []string {
- return []string{"public"} //TODO: config
+ return []string{"public"} // TODO: config
}
func SigAlgorithms(ctx context.Context, storage DiscoverStorage) []string {
diff --git a/pkg/op/discovery_test.go b/pkg/op/discovery_test.go
index cb4cfba..61afb62 100644
--- a/pkg/op/discovery_test.go
+++ b/pkg/op/discovery_test.go
@@ -81,6 +81,11 @@ func Test_scopes(t *testing.T) {
args{},
op.DefaultSupportedScopes,
},
+ {
+ "custom scopes",
+ args{newTestProvider(&op.Config{SupportedScopes: []string{"test1", "test2"}})},
+ []string{"test1", "test2"},
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
diff --git a/pkg/op/error_test.go b/pkg/op/error_test.go
index 170039c..107f9d0 100644
--- a/pkg/op/error_test.go
+++ b/pkg/op/error_test.go
@@ -428,7 +428,8 @@ func TestTryErrorRedirect(t *testing.T) {
parent: oidc.ErrInteractionRequired().WithDescription("sign in"),
},
want: &Redirect{
- URL: "http://example.com/callback?error=interaction_required&error_description=sign+in&state=state1",
+ Header: make(http.Header),
+ URL: "http://example.com/callback?error=interaction_required&error_description=sign+in&state=state1",
},
wantLog: `{
"level":"WARN",
diff --git a/pkg/op/mock/configuration.mock.go b/pkg/op/mock/configuration.mock.go
index f392a45..137c09d 100644
--- a/pkg/op/mock/configuration.mock.go
+++ b/pkg/op/mock/configuration.mock.go
@@ -78,6 +78,34 @@ func (mr *MockConfigurationMockRecorder) AuthorizationEndpoint() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthorizationEndpoint", reflect.TypeOf((*MockConfiguration)(nil).AuthorizationEndpoint))
}
+// BackChannelLogoutSessionSupported mocks base method.
+func (m *MockConfiguration) BackChannelLogoutSessionSupported() bool {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "BackChannelLogoutSessionSupported")
+ ret0, _ := ret[0].(bool)
+ return ret0
+}
+
+// BackChannelLogoutSessionSupported indicates an expected call of BackChannelLogoutSessionSupported.
+func (mr *MockConfigurationMockRecorder) BackChannelLogoutSessionSupported() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BackChannelLogoutSessionSupported", reflect.TypeOf((*MockConfiguration)(nil).BackChannelLogoutSessionSupported))
+}
+
+// BackChannelLogoutSupported mocks base method.
+func (m *MockConfiguration) BackChannelLogoutSupported() bool {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "BackChannelLogoutSupported")
+ ret0, _ := ret[0].(bool)
+ return ret0
+}
+
+// BackChannelLogoutSupported indicates an expected call of BackChannelLogoutSupported.
+func (mr *MockConfigurationMockRecorder) BackChannelLogoutSupported() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BackChannelLogoutSupported", reflect.TypeOf((*MockConfiguration)(nil).BackChannelLogoutSupported))
+}
+
// CodeMethodS256Supported mocks base method.
func (m *MockConfiguration) CodeMethodS256Supported() bool {
m.ctrl.T.Helper()
diff --git a/pkg/op/op.go b/pkg/op/op.go
index 61c2449..190c2c4 100644
--- a/pkg/op/op.go
+++ b/pkg/op/op.go
@@ -158,16 +158,19 @@ func authCallbackPath(o OpenIDProvider) string {
}
type Config struct {
- CryptoKey [32]byte
- DefaultLogoutRedirectURI string
- CodeMethodS256 bool
- AuthMethodPost bool
- AuthMethodPrivateKeyJWT bool
- GrantTypeRefreshToken bool
- RequestObjectSupported bool
- SupportedUILocales []language.Tag
- SupportedClaims []string
- DeviceAuthorization DeviceAuthorizationConfig
+ CryptoKey [32]byte
+ DefaultLogoutRedirectURI string
+ CodeMethodS256 bool
+ AuthMethodPost bool
+ AuthMethodPrivateKeyJWT bool
+ GrantTypeRefreshToken bool
+ RequestObjectSupported bool
+ SupportedUILocales []language.Tag
+ SupportedClaims []string
+ SupportedScopes []string
+ DeviceAuthorization DeviceAuthorizationConfig
+ BackChannelLogoutSupported bool
+ BackChannelLogoutSessionSupported bool
}
// Endpoints defines endpoint routes.
@@ -411,6 +414,14 @@ func (o *Provider) DeviceAuthorization() DeviceAuthorizationConfig {
return o.config.DeviceAuthorization
}
+func (o *Provider) BackChannelLogoutSupported() bool {
+ return o.config.BackChannelLogoutSupported
+}
+
+func (o *Provider) BackChannelLogoutSessionSupported() bool {
+ return o.config.BackChannelLogoutSessionSupported
+}
+
func (o *Provider) Storage() Storage {
return o.storage
}
diff --git a/pkg/op/op_test.go b/pkg/op/op_test.go
index 83032d4..9a4a624 100644
--- a/pkg/op/op_test.go
+++ b/pkg/op/op_test.go
@@ -232,7 +232,7 @@ func TestRoutes(t *testing.T) {
"scope": oidc.SpaceDelimitedArray{oidc.ScopeOpenID, oidc.ScopeOfflineAccess}.String(),
},
wantCode: http.StatusOK,
- contains: []string{`{"access_token":"`, `","token_type":"Bearer","expires_in":299}`},
+ contains: []string{`{"access_token":"`, `","token_type":"Bearer","expires_in":299,"scope":"openid offline_access"}`},
},
{
// This call will fail. A successful test is already
diff --git a/pkg/op/server.go b/pkg/op/server.go
index 829618c..b500e43 100644
--- a/pkg/op/server.go
+++ b/pkg/op/server.go
@@ -218,7 +218,8 @@ type Response struct {
// without custom headers.
func NewResponse(data any) *Response {
return &Response{
- Data: data,
+ Header: make(http.Header),
+ Data: data,
}
}
@@ -242,11 +243,14 @@ type Redirect struct {
}
func NewRedirect(url string) *Redirect {
- return &Redirect{URL: url}
+ return &Redirect{
+ Header: make(http.Header),
+ URL: url,
+ }
}
func (red *Redirect) writeOut(w http.ResponseWriter, r *http.Request) {
- gu.MapMerge(r.Header, w.Header())
+ gu.MapMerge(red.Header, w.Header())
http.Redirect(w, r, red.URL, http.StatusFound)
}
diff --git a/pkg/op/server_http_routes_test.go b/pkg/op/server_http_routes_test.go
index 2c83ad3..1bfb32b 100644
--- a/pkg/op/server_http_routes_test.go
+++ b/pkg/op/server_http_routes_test.go
@@ -145,7 +145,7 @@ func TestServerRoutes(t *testing.T) {
"assertion": jwtProfileToken,
},
wantCode: http.StatusOK,
- contains: []string{`{"access_token":`, `"token_type":"Bearer","expires_in":299}`},
+ contains: []string{`{"access_token":`, `"token_type":"Bearer","expires_in":299,"scope":"openid"}`},
},
{
name: "Token exchange",
@@ -174,7 +174,7 @@ func TestServerRoutes(t *testing.T) {
"scope": oidc.SpaceDelimitedArray{oidc.ScopeOpenID, oidc.ScopeOfflineAccess}.String(),
},
wantCode: http.StatusOK,
- contains: []string{`{"access_token":"`, `","token_type":"Bearer","expires_in":299}`},
+ contains: []string{`{"access_token":"`, `","token_type":"Bearer","expires_in":299,"scope":"openid offline_access"}`},
},
{
// This call will fail. A successful test is already
diff --git a/pkg/op/token.go b/pkg/op/token.go
index b45789b..04cd3cc 100644
--- a/pkg/op/token.go
+++ b/pkg/op/token.go
@@ -2,11 +2,11 @@ package op
import (
"context"
+ "slices"
"time"
"github.com/zitadel/oidc/v3/pkg/crypto"
"github.com/zitadel/oidc/v3/pkg/oidc"
- "github.com/zitadel/oidc/v3/pkg/strings"
)
type TokenCreator interface {
@@ -65,6 +65,7 @@ func CreateTokenResponse(ctx context.Context, request IDTokenRequest, client Cli
TokenType: oidc.BearerToken,
ExpiresIn: exp,
State: state,
+ Scope: request.GetScopes(),
}, nil
}
@@ -82,13 +83,13 @@ func createTokens(ctx context.Context, tokenRequest TokenRequest, storage Storag
func needsRefreshToken(tokenRequest TokenRequest, client AccessTokenClient) bool {
switch req := tokenRequest.(type) {
case AuthRequest:
- return strings.Contains(req.GetScopes(), oidc.ScopeOfflineAccess) && req.GetResponseType() == oidc.ResponseTypeCode && ValidateGrantType(client, oidc.GrantTypeRefreshToken)
+ return slices.Contains(req.GetScopes(), oidc.ScopeOfflineAccess) && req.GetResponseType() == oidc.ResponseTypeCode && ValidateGrantType(client, oidc.GrantTypeRefreshToken)
case TokenExchangeRequest:
return req.GetRequestedTokenType() == oidc.RefreshTokenType
case RefreshTokenRequest:
return true
case *DeviceAuthorizationState:
- return strings.Contains(req.GetScopes(), oidc.ScopeOfflineAccess) && ValidateGrantType(client, oidc.GrantTypeRefreshToken)
+ return slices.Contains(req.GetScopes(), oidc.ScopeOfflineAccess) && ValidateGrantType(client, oidc.GrantTypeRefreshToken)
default:
return false
}
diff --git a/pkg/op/token_client_credentials.go b/pkg/op/token_client_credentials.go
index 7f1debe..63dcc79 100644
--- a/pkg/op/token_client_credentials.go
+++ b/pkg/op/token_client_credentials.go
@@ -120,5 +120,6 @@ func CreateClientCredentialsTokenResponse(ctx context.Context, tokenRequest Toke
AccessToken: accessToken,
TokenType: oidc.BearerToken,
ExpiresIn: uint64(validity.Seconds()),
+ Scope: tokenRequest.GetScopes(),
}, nil
}
diff --git a/pkg/op/token_jwt_profile.go b/pkg/op/token_jwt_profile.go
index 96ce1ed..d1a7ff5 100644
--- a/pkg/op/token_jwt_profile.go
+++ b/pkg/op/token_jwt_profile.go
@@ -89,6 +89,7 @@ func CreateJWTTokenResponse(ctx context.Context, tokenRequest TokenRequest, crea
AccessToken: accessToken,
TokenType: oidc.BearerToken,
ExpiresIn: uint64(validity.Seconds()),
+ Scope: tokenRequest.GetScopes(),
}, nil
}
diff --git a/pkg/op/token_refresh.go b/pkg/op/token_refresh.go
index 92ef476..7c8c1c0 100644
--- a/pkg/op/token_refresh.go
+++ b/pkg/op/token_refresh.go
@@ -4,11 +4,11 @@ import (
"context"
"errors"
"net/http"
+ "slices"
"time"
httphelper "github.com/zitadel/oidc/v3/pkg/http"
"github.com/zitadel/oidc/v3/pkg/oidc"
- "github.com/zitadel/oidc/v3/pkg/strings"
)
type RefreshTokenRequest interface {
@@ -85,7 +85,7 @@ func ValidateRefreshTokenScopes(requestedScopes []string, authRequest RefreshTok
return nil
}
for _, scope := range requestedScopes {
- if !strings.Contains(authRequest.GetScopes(), scope) {
+ if !slices.Contains(authRequest.GetScopes(), scope) {
return oidc.ErrInvalidScope()
}
}
diff --git a/pkg/strings/strings.go b/pkg/strings/strings.go
index af48cf3..b8f43a1 100644
--- a/pkg/strings/strings.go
+++ b/pkg/strings/strings.go
@@ -1,10 +1,9 @@
package strings
+import "slices"
+
+// Deprecated: Use standard library [slices.Contains] instead.
func Contains(list []string, needle string) bool {
- for _, item := range list {
- if item == needle {
- return true
- }
- }
- return false
+ // TODO(v4): remove package.
+ return slices.Contains(list, needle)
}