Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ Here is json equivalent for one of the default users
| 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 |
| Key Binding | yes | yes | OpenID Connect [Key Binding][13] 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"
Expand All @@ -163,6 +164,7 @@ Here is json equivalent for one of the default users
[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"
[13]: https://openid.net/specs/openid-connect-key-binding-1_0.html "OpenID Connect Key Binding 1.0 (draft 02)"

## Contributors

Expand Down
69 changes: 69 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,75 @@ All commands are executed from the root of the project that imports oidc package
on non-GNU systems, such as MacOS.
Alternatively, GNU sed can be installed on such systems. (`coreutils` package?).

## OpenID Connect Key Binding

Key Binding is opt-in and off by default. Existing OPs and RPs need no changes: an OP only performs key binding once its storage implements `op.BoundKeyRequest`, so a storage predating this feature is unaffected even if its `Client.IsScopeAllowed` happens to permit the `bound_key` scope.

Key Binding 1.0 is still an OpenID draft, so the API is marked `EXPERIMENTAL: may change until v4`.

### Relying party

Pass a `crypto.Signer` (including KMS- or HSM-backed ones) and its algorithm:

```go
provider, err := rp.NewRelyingPartyOIDC(ctx, issuer, clientID, clientSecret, redirectURI,
scopes, rp.WithKeyBinding(signer, jose.ES256))
```

The RP then appends the `bound_key` scope, sends `dpop_jkt` on the Authentication Request, signs a DPoP proof for the code and refresh token requests, and verifies that the returned ID Token is actually bound to `signer`. A stripped binding is an error rather than a silent downgrade to a bearer ID Token.

The Device Authorization flow is supported as well: `rp.DeviceAuthorization` adds `bound_key` and `dpop_jkt`, and `rp.DeviceAccessToken` signs a proof bound to the `device_code` on every poll and verifies the returned ID Token. No code change is needed beyond `WithKeyBinding`.

### OpenID Provider

Two changes are required.

1. Enable the feature, which advertises the `bound_key` scope in `scopes_supported` and populates `dpop_signing_alg_values_supported`:

```go
provider, err := op.NewOpenIDProvider(issuer, config, storage, op.WithKeyBinding())
```

Adding `oidc.ScopeBoundKey` to `op.Config.SupportedScopes` yourself is equivalent for discovery purposes. `op.WithKeyBinding()` is preferred because it also validates the storage at construction (see the device flow below).

2. Persist `dpop_jkt` and expose it from your stored authorization request and refresh token request types by implementing the optional `op.BoundKeyRequest` interface:

```go
type BoundKeyRequest interface {
GetDPoPJKT() string
}
```

Store `oidc.AuthRequest.DPoPJKT` when the Authentication Request is created and carry it across refresh token rotation. See `example/server/storage` for a complete implementation.

Implementing `op.BoundKeyRequest` is the signal that your storage can honour a binding:

- If the persisted request does not implement `op.BoundKeyRequest`, key binding is not integrated, so `bound_key` is ignored and an ordinary bearer ID Token is issued. Section 2.1 of the specification permits this, and a key-binding RP still fails closed because it verifies the returned `typ` and `cnf`. This is deliberate: `bound_key` is granted via `Client.IsScopeAllowed`, and a permissive implementation written before this scope existed may grant scopes it knows nothing about. Such an OP must not start returning errors.
- If the persisted request implements it but returns an empty or malformed `GetDPoPJKT()` for a request granted `bound_key`, that is a bug in the OP, not an unsupported feature, so the token endpoint fails with `server_error` rather than issuing an unbound token.

The same check is enforced centrally in `CreateIDToken`, so flows that do not implement key binding, i.e. Token Exchange and the implicit/hybrid flows, reject a bound request rather than returning an unbound ID Token.

### Device Authorization flow

If you support the `device_code` grant and use `op.WithKeyBinding()`, your storage must also implement `op.BoundKeyDeviceAuthorizationStorage`. This is checked at construction and `op.NewOpenIDProvider` returns an error rather than letting the OP start in a state where key-bound device requests fail.

```go
StoreBoundKeyDeviceAuthorization(ctx context.Context, clientID, deviceCode, userCode string,
expires time.Time, scopes []string, dpopJKT string) error
```

This exists as a separate optional interface because `StoreDeviceAuthorization` cannot gain a parameter without breaking every implementation. Persist `dpopJKT` and return it from `op.DeviceAuthorizationState.DPoPJKT` in `GetDeviceAuthorizatonState`; the proof is bound to the `device_code` (`c_s256 = SHA256(device_code)`).

Implementing this interface is what enables key binding on the device flow, just as `op.BoundKeyRequest` does for the code and refresh flows. If the storage does not implement it, a `bound_key` device authorization request is downgraded to an ordinary unbound one: both `bound_key` and `dpop_jkt` are dropped before the authorization is stored. The scope has to be dropped along with the thumbprint because `op.DeviceAuthorizationState` always satisfies `op.BoundKeyRequest`, so a retained scope with no thumbprint would look like a lost binding and fail closed at the token endpoint. Because `op.WithKeyBinding()` rejects such a storage at construction, this downgrade can only affect an OP that never enabled key binding, and a key-binding RP still fails closed on the missing `cnf`.

If the device grant also issues refresh tokens, carry the thumbprint onto the refresh token record as well — see the `getInfoFromRequest` case for `*op.DeviceAuthorizationState` in `example/server/storage`.

Two storage requirements are worth calling out explicitly, because the library cannot enforce them for you:

Access tokens remain bearer tokens; only the ID Token is bound. DPoP headers on requests without a binding are ignored, so DPoP-bound access-token clients are unaffected.

A key-bound ID Token is rejected as a Token Exchange `subject_token` / `actor_token`, because possession of the binding key cannot be proven on that request; accepting it would convert a proof-of-possession token back into a bearer token.

## Global `slog` logger migration

OIDC now logs through the global `log/slog` functions. Configure the process-wide default before constructing an OP or RP:
Expand Down
2 changes: 2 additions & 0 deletions example/server/dynamic/op.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ func newDynamicOP(ctx context.Context, storage op.Storage, key [32]byte, keyId s
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")),
// enable OpenID Connect Key Binding (advertises the bound_key scope)
op.WithKeyBinding(),
)
if err != nil {
return nil, err
Expand Down
2 changes: 2 additions & 0 deletions example/server/exampleop/op.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ func newOP(
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")),
// enable OpenID Connect Key Binding (advertises the bound_key scope)
op.WithKeyBinding(),
}, extraOptions...)...,
)
if err != nil {
Expand Down
4 changes: 2 additions & 2 deletions example/server/storage/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,9 @@ func (c *Client) RestrictAdditionalAccessTokenScopes() func(scopes []string) []s
}

// IsScopeAllowed enables Client specific custom scopes validation
// in this example we allow the CustomScope for all clients
// in this example we allow the CustomScope and bound_key for all clients
func (c *Client) IsScopeAllowed(scope string) bool {
return scope == CustomScope
return scope == CustomScope || scope == oidc.ScopeBoundKey
}

// IDTokenUserinfoClaimsAssertion allows specifying if claims of scope profile, email, phone and address are asserted into the id_token
Expand Down
10 changes: 10 additions & 0 deletions example/server/storage/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type AuthRequest struct {
ResponseType oidc.ResponseType
ResponseMode oidc.ResponseMode
Nonce string
DPoPJKT string
CodeChallenge *OIDCCodeChallenge

done bool
Expand Down Expand Up @@ -92,6 +93,10 @@ func (a *AuthRequest) GetNonce() string {
return a.Nonce
}

func (a *AuthRequest) GetDPoPJKT() string {
return a.DPoPJKT
}

func (a *AuthRequest) GetRedirectURI() string {
return a.CallbackURI
}
Expand Down Expand Up @@ -165,6 +170,7 @@ func authRequestToInternal(authReq *oidc.AuthRequest, userID string) *AuthReques
ResponseType: authReq.ResponseType,
ResponseMode: authReq.ResponseMode,
Nonce: authReq.Nonce,
DPoPJKT: authReq.DPoPJKT,
CodeChallenge: codeChallenge,
}
}
Expand Down Expand Up @@ -222,6 +228,10 @@ func (r *RefreshTokenRequest) GetClientID() string {
return r.ApplicationID
}

func (r *RefreshTokenRequest) GetDPoPJKT() string {
return r.DPoPJKT
}

func (r *RefreshTokenRequest) GetScopes() []string {
return r.Scopes
}
Expand Down
17 changes: 9 additions & 8 deletions example/server/storage/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ func (s *Storage) CreateAccessAndRefreshTokens(ctx context.Context, request op.T
}

// get the information depending on the request type / implementation
applicationID, authTime, amr := getInfoFromRequest(request)
applicationID, authTime, amr, dpopJKT := getInfoFromRequest(request)

// if currentRefreshToken is empty (Code Flow) we will have to create a new refresh token
if currentRefreshToken == "" {
Expand All @@ -289,7 +289,7 @@ func (s *Storage) CreateAccessAndRefreshTokens(ctx context.Context, request op.T
if err != nil {
return "", "", time.Time{}, err
}
refreshToken, err := s.createRefreshToken(accessToken, amr, authTime)
refreshToken, err := s.createRefreshToken(accessToken, amr, authTime, dpopJKT)
if err != nil {
return "", "", time.Time{}, err
}
Expand Down Expand Up @@ -323,7 +323,7 @@ func (s *Storage) exchangeRefreshToken(ctx context.Context, request op.TokenExch
return "", "", time.Time{}, err
}

refreshToken, err := s.createRefreshToken(accessToken, nil, authTime)
refreshToken, err := s.createRefreshToken(accessToken, nil, authTime, "")
if err != nil {
return "", "", time.Time{}, err
}
Expand Down Expand Up @@ -591,7 +591,7 @@ func (s *Storage) Health(ctx context.Context) error {
}

// createRefreshToken will store a refresh_token in-memory based on the provided information
func (s *Storage) createRefreshToken(accessToken *Token, amr []string, authTime time.Time) (string, error) {
func (s *Storage) createRefreshToken(accessToken *Token, amr []string, authTime time.Time, dpopJKT string) (string, error) {
s.lock.Lock()
defer s.lock.Unlock()
token := &RefreshToken{
Expand All @@ -604,6 +604,7 @@ func (s *Storage) createRefreshToken(accessToken *Token, amr []string, authTime
Audience: accessToken.Audience,
Expiration: time.Now().Add(5 * time.Hour),
Scopes: accessToken.Scopes,
DPoPJKT: dpopJKT,
AccessToken: accessToken.ID,
}
s.refreshTokens[token.ID] = token
Expand Down Expand Up @@ -786,16 +787,16 @@ func (s *Storage) getTokenExchangeClaims(ctx context.Context, request op.TokenEx
}

// 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) {
func getInfoFromRequest(req op.TokenRequest) (clientID string, authTime time.Time, amr []string, dpopJKT string) {
authReq, ok := req.(*AuthRequest) // Code Flow (with scope offline_access)
if ok {
return authReq.ApplicationID, authReq.authTime, authReq.GetAMR()
return authReq.ApplicationID, authReq.authTime, authReq.GetAMR(), authReq.DPoPJKT
}
refreshReq, ok := req.(*RefreshTokenRequest) // Refresh Token Request
if ok {
return refreshReq.ApplicationID, refreshReq.AuthTime, refreshReq.AMR
return refreshReq.ApplicationID, refreshReq.AuthTime, refreshReq.AMR, refreshReq.DPoPJKT
}
return "", time.Time{}, nil
return "", time.Time{}, nil, ""
}

// customClaim demonstrates how to return custom claims based on provided information
Expand Down
1 change: 1 addition & 0 deletions example/server/storage/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,6 @@ type RefreshToken struct {
ApplicationID string
Expiration time.Time
Scopes []string
DPoPJKT string
AccessToken string // Token.ID
}
159 changes: 159 additions & 0 deletions pkg/client/key_binding_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package client_test

import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"

jose "github.com/go-jose/go-jose/v4"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/zitadel/oidc/v3/example/server/exampleop"
"github.com/zitadel/oidc/v3/example/server/storage"
"github.com/zitadel/oidc/v3/pkg/client/rp"
"github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/oidc/v3/pkg/op"
)

func TestNativeKeyBinding(t *testing.T) {
for _, wrapServer := range []bool{false, true} {
t.Run(fmt.Sprintf("legacy_server=%t", wrapServer), func(t *testing.T) {
testNativeKeyBinding(t, wrapServer)
})
}
}

func testNativeKeyBinding(t *testing.T, wrapServer bool) {
ctx := context.Background()
exampleStorage := storage.NewStorage(storage.NewUserStore("http://local-site"))
var deferred deferredHandler
opServer := httptest.NewServer(&deferred)
defer opServer.Close()
deferred.Handler = exampleop.SetupServer(opServer.URL, exampleStorage, Logger, wrapServer)

clientID := "key-binding-" + uuid.NewString()
const clientSecret = "secret"
const redirectURI = "http://local-site/callback"
storage.RegisterClients(storage.WebClient(clientID, clientSecret, redirectURI))

key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
jkt, err := oidc.JWKThumbprint(&jose.JSONWebKey{Key: &key.PublicKey})
require.NoError(t, err)
provider, err := rp.NewRelyingPartyOIDC(
ctx,
opServer.URL,
clientID,
clientSecret,
redirectURI,
[]string{oidc.ScopeOpenID, oidc.ScopeOfflineAccess},
rp.WithKeyBinding(key, jose.ES256),
)
require.NoError(t, err)

issuerCtx := op.ContextWithIssuer(ctx, opServer.URL)
authorizationURL, err := url.Parse(rp.AuthURL("state", provider))
require.NoError(t, err)
query := authorizationURL.Query()
assert.Equal(t, jkt, query.Get(oidc.DPoPJKTParam))
assert.Contains(t, query.Get("scope"), oidc.ScopeBoundKey)
authClient := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
authResponse, err := authClient.Get(authorizationURL.String())
require.NoError(t, err)
defer authResponse.Body.Close()
require.Equal(t, http.StatusFound, authResponse.StatusCode)
loginURL, err := authResponse.Location()
require.NoError(t, err)
authRequestID := loginURL.Query().Get("authRequestID")
require.NotEmpty(t, authRequestID)
authRequest, err := exampleStorage.AuthRequestByID(issuerCtx, authRequestID)
require.NoError(t, err)
boundRequest, ok := authRequest.(op.BoundKeyRequest)
require.True(t, ok)
assert.Equal(t, jkt, boundRequest.GetDPoPJKT())
require.NoError(t, exampleStorage.CheckUsernamePassword("test-user@local-site", "verysecure", authRequest.GetID()))
code := uuid.NewString()
require.NoError(t, exampleStorage.SaveAuthCode(issuerCtx, authRequest.GetID(), code))
plainProvider, err := rp.NewRelyingPartyOIDC(
ctx,
opServer.URL,
clientID,
clientSecret,
redirectURI,
[]string{oidc.ScopeOpenID, oidc.ScopeOfflineAccess},
)
require.NoError(t, err)
_, err = rp.CodeExchange[*oidc.IDTokenClaims](ctx, code, plainProvider)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid_dpop_proof")

tokens, err := rp.CodeExchange[*oidc.IDTokenClaims](ctx, code, provider)
require.NoError(t, err)
require.NotNil(t, tokens.IDTokenClaims.Confirmation)
assertConfirmationThumbprint(t, tokens.IDTokenClaims.Confirmation, jkt)
assertIDTokenType(t, tokens.IDToken, oidc.IDTokenTypeDPoP)
require.NotEmpty(t, tokens.RefreshToken)

wrongKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
wrongKeyProvider, err := rp.NewRelyingPartyOIDC(
ctx,
opServer.URL,
clientID,
clientSecret,
redirectURI,
[]string{oidc.ScopeOpenID},
rp.WithKeyBinding(wrongKey, jose.ES256),
)
require.NoError(t, err)
_, err = rp.RefreshTokens[*oidc.IDTokenClaims](ctx, wrongKeyProvider, tokens.RefreshToken, "", "")
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid_dpop_proof")

refreshed, err := rp.RefreshTokens[*oidc.IDTokenClaims](ctx, provider, tokens.RefreshToken, "", "")
require.NoError(t, err)
require.NotNil(t, refreshed.IDTokenClaims.Confirmation)
assertConfirmationThumbprint(t, refreshed.IDTokenClaims.Confirmation, jkt)
assertIDTokenType(t, refreshed.IDToken, oidc.IDTokenTypeDPoP)

unboundRequest, err := exampleStorage.CreateAuthRequest(issuerCtx, &oidc.AuthRequest{
ClientID: clientID,
RedirectURI: redirectURI,
Scopes: oidc.SpaceDelimitedArray{oidc.ScopeOpenID},
ResponseType: oidc.ResponseTypeCode,
}, "id1")
require.NoError(t, err)
require.NoError(t, exampleStorage.AuthRequestDone(unboundRequest.GetID()))
unboundCode := uuid.NewString()
require.NoError(t, exampleStorage.SaveAuthCode(issuerCtx, unboundRequest.GetID(), unboundCode))
unboundTokens, err := rp.CodeExchange[*oidc.IDTokenClaims](ctx, unboundCode, plainProvider)
require.NoError(t, err)
assert.Nil(t, unboundTokens.IDTokenClaims.Confirmation)
assertIDTokenType(t, unboundTokens.IDToken, "JWT")
}

func assertConfirmationThumbprint(t *testing.T, confirmation *oidc.Confirmation, want string) {
t.Helper()
var jwk jose.JSONWebKey
require.NoError(t, json.Unmarshal(confirmation.JWK, &jwk))
got, err := oidc.JWKThumbprint(&jwk)
require.NoError(t, err)
assert.Equal(t, want, got)
}

func assertIDTokenType(t *testing.T, token string, want jose.ContentType) {
t.Helper()
jws, err := jose.ParseSigned(token, []jose.SignatureAlgorithm{jose.RS256})
require.NoError(t, err)
require.Len(t, jws.Signatures, 1)
assert.Equal(t, string(want), jws.Signatures[0].Header.ExtraHeaders[jose.HeaderType])
}
3 changes: 2 additions & 1 deletion pkg/client/rp/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,6 @@ func DeviceAccessToken(ctx context.Context, deviceCode string, interval time.Dur
}

}
return client.PollDeviceAccessTokenEndpointWithAuthFn(ctx, interval, req, tokenEndpointCaller{rp}, authFn)

return client.PollDeviceAccessTokenEndpointWithAuthFn(ctx, interval, req, tokenEndpointCaller{RelyingParty: rp}, authFn)
}
Loading