Skip to content
Open
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
17 changes: 17 additions & 0 deletions pkg/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,23 @@ type DeviceAuthorizationCaller interface {
}

func CallDeviceAuthorizationEndpoint(ctx context.Context, request *oidc.ClientCredentialsRequest, caller DeviceAuthorizationCaller, authFn any) (*oidc.DeviceAuthorizationResponse, error) {
return callDeviceAuthorizationEndpoint(ctx, request, caller, authFn)
}

// BoundKeyDeviceAuthorizationRequest adds the `dpop_jkt` parameter from
// OpenID Connect Key Binding 1.0, Section 3.1 to a Device Authorization Request.
type BoundKeyDeviceAuthorizationRequest struct {
*oidc.ClientCredentialsRequest
DPoPJKT string `schema:"dpop_jkt,omitempty"`
}

// CallDeviceAuthorizationEndpointWithBoundKey is [CallDeviceAuthorizationEndpoint]
// that includes a request with an OpenID Key Binding proof-of-possession key.
func CallDeviceAuthorizationEndpointWithBoundKey(ctx context.Context, request *BoundKeyDeviceAuthorizationRequest, caller DeviceAuthorizationCaller, authFn any) (*oidc.DeviceAuthorizationResponse, error) {
return callDeviceAuthorizationEndpoint(ctx, request, caller, authFn)
}

func callDeviceAuthorizationEndpoint(ctx context.Context, request any, caller DeviceAuthorizationCaller, authFn any) (*oidc.DeviceAuthorizationResponse, error) {
ctx, span := Tracer.Start(ctx, "CallDeviceAuthorizationEndpoint")
defer span.End()

Expand Down
52 changes: 49 additions & 3 deletions pkg/client/rp/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package rp
import (
"context"
"fmt"
"slices"
"time"

"github.com/zitadel/oidc/v3/pkg/client"
Expand Down Expand Up @@ -34,21 +35,36 @@ func newDeviceClientCredentialsRequest(scopes []string, rp RelyingParty) (*oidc.
// DeviceAuthorization starts a new Device Authorization flow as defined
// in RFC 8628, section 3.1 and 3.2:
// https://www.rfc-editor.org/rfc/rfc8628#section-3.1
// When the RelyingParty is configured with [WithKeyBinding], the `bound_key`
// scope and the `dpop_jkt` parameter are added.
func DeviceAuthorization(ctx context.Context, scopes []string, rp RelyingParty, authFn any) (*oidc.DeviceAuthorizationResponse, error) {
ctx, span := client.Tracer.Start(ctx, "DeviceAuthorization")
defer span.End()

configured, bound := keyBindingRP(rp)
if bound && !slices.Contains(scopes, oidc.ScopeBoundKey) {
scopes = append(slices.Clone(scopes), oidc.ScopeBoundKey)
}

req, err := newDeviceClientCredentialsRequest(scopes, rp)
if err != nil {
return nil, err
}

return client.CallDeviceAuthorizationEndpoint(ctx, req, rp, authFn)
if !bound {
return client.CallDeviceAuthorizationEndpoint(ctx, req, rp, authFn)
}
return client.CallDeviceAuthorizationEndpointWithBoundKey(ctx, &client.BoundKeyDeviceAuthorizationRequest{
ClientCredentialsRequest: req,
DPoPJKT: configured.KeyBindingThumbprint(),
}, rp, authFn)
}

// DeviceAccessToken attempts to obtain tokens from a Device Authorization,
// by means of polling as defined in RFC, section 3.3 and 3.4:
// https://www.rfc-editor.org/rfc/rfc8628#section-3.4
//
// When the RelyingParty is configured with [WithKeyBinding], each poll carries a
// DPoP proof bound to deviceCode.
func DeviceAccessToken(ctx context.Context, deviceCode string, interval time.Duration, rp RelyingParty) (resp *oidc.AccessTokenResponse, err error) {
ctx, span := client.Tracer.Start(ctx, "DeviceAccessToken")
defer span.End()
Expand Down Expand Up @@ -82,5 +98,35 @@ func DeviceAccessToken(ctx context.Context, deviceCode string, interval time.Dur
}

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

caller := tokenEndpointCaller{RelyingParty: rp}
configured, bound := keyBindingRP(rp)
if bound {
// The proof is over the device code (c_s256)
caller.httpClient = keyBindingHTTPClient(rp.HttpClient(), configured, deviceCode, rp.OAuthConfig().Endpoint.TokenURL)
}

resp, err = client.PollDeviceAccessTokenEndpointWithAuthFn(ctx, interval, req, caller, authFn)
if err != nil {
return nil, err
}
if bound {
if err := verifyDeviceKeyBinding(ctx, resp, rp, configured); err != nil {
return nil, err
}
}
return resp, nil
}

// verifyDeviceKeyBinding checks that the ID Token returned by the device token
// endpoint is actually bound to the expected RP's key.
func verifyDeviceKeyBinding(ctx context.Context, resp *oidc.AccessTokenResponse, rp RelyingParty, configured KeyBindingRelyingParty) error {
if resp.IDToken == "" {
return fmt.Errorf("%w: no id_token returned for a bound_key request", ErrKeyBindingIDToken)
}
idToken, err := VerifyIDToken[*oidc.IDTokenClaims](ctx, resp.IDToken, rp.IDTokenVerifier())
if err != nil {
return err
}
return verifyKeyBindingIDToken(resp.IDToken, idToken.GetSignatureAlgorithm(), configured.KeyBindingThumbprint())
}
Loading
Loading