Repository: zitadel/oidc (go)

Issue:
return an ID token with Device Authorizaiton
Although Device Authorization is an Oauth2-only extension (there is no mention in the OIDC standard) it would be useful if we return an ID Token is the requested scope is set to `openid`. This is to have a compatible result with the rest of the toolkit, regardless of the method of authentication flow that is choosen.

## Acceptance criteria

- [ ] When scope `openid` is set, an ID Token to be returned to the device.

Pull request description:
feat(op): ID token for device authorization grant
This change allows returning of an ID Token in the Device Authorization grant,
when the `openid` scope is requested.
This is a non-standard add-on that crosses OpenID Connect with Oauth2 behavior.
Device Authorization grant is a Oauth2-only standard and therefore ID tokens are normally not part
of the grant type.

closes #426

### Definition of Ready

- [x] I am happy with the code
- [x] Short description of the feature/issue is added in the pr description
- [x] PR is linked to the corresponding user story
- [x] Acceptance criteria are met
- [x] All open todos and follow ups are defined in a new ticket and justified
- [x] Deviations from the acceptance criteria and design are agreed with the PO and documented.
- [x] No debug or dead code
- [x] My code has no repetitions
- [x] Critical parts are tested automatically
- [x] Where possible E2E tests are implemented
- [x] Documentation/examples are up-to-date
- [x] All non-functional requirements are met
- [x] Functionality of the acceptance criteria is checked manually on the dev system.



Patch:
diff --git a/pkg/op/device.go b/pkg/op/device.go
index 813c3f5..1b86d04 100644
--- a/pkg/op/device.go
+++ b/pkg/op/device.go
@@ -14,6 +14,7 @@ import (
 
 	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 {
@@ -185,24 +186,6 @@ func NewUserCode(charSet []rune, charAmount, dashInterval int) (string, error) {
 	return buf.String(), nil
 }
 
-type deviceAccessTokenRequest struct {
-	subject  string
-	audience []string
-	scopes   []string
-}
-
-func (r *deviceAccessTokenRequest) GetSubject() string {
-	return r.subject
-}
-
-func (r *deviceAccessTokenRequest) GetAudience() []string {
-	return r.audience
-}
-
-func (r *deviceAccessTokenRequest) GetScopes() []string {
-	return r.scopes
-}
-
 func DeviceAccessToken(w http.ResponseWriter, r *http.Request, exchanger Exchanger) {
 	ctx, span := tracer.Start(r.Context(), "DeviceAccessToken")
 	defer span.End()
@@ -229,7 +212,7 @@ func deviceAccessToken(w http.ResponseWriter, r *http.Request, exchanger Exchang
 	if err != nil {
 		return err
 	}
-	state, err := CheckDeviceAuthorizationState(ctx, clientID, req.DeviceCode, exchanger)
+	tokenRequest, err := CheckDeviceAuthorizationState(ctx, clientID, req.DeviceCode, exchanger)
 	if err != nil {
 		return err
 	}
@@ -243,11 +226,6 @@ func deviceAccessToken(w http.ResponseWriter, r *http.Request, exchanger Exchang
 			WithDescription("confidential client requires authentication")
 	}
 
-	tokenRequest := &deviceAccessTokenRequest{
-		subject:  state.Subject,
-		audience: []string{clientID},
-		scopes:   state.Scopes,
-	}
 	resp, err := CreateDeviceTokenResponse(r.Context(), tokenRequest, exchanger, client)
 	if err != nil {
 		return err
@@ -265,6 +243,50 @@ func ParseDeviceAccessTokenRequest(r *http.Request, exchanger Exchanger) (*oidc.
 	return req, nil
 }
 
+// DeviceAuthorizationState describes the current state of
+// the device authorization flow.
+// It implements the [IDTokenRequest] interface.
+type DeviceAuthorizationState struct {
+	ClientID string
+	Audience []string
+	Scopes   []string
+	Expires  time.Time // The time after we consider the authorization request timed-out
+	Done     bool      // The user authenticated and approved the authorization request
+	Denied   bool      // The user authenticated and denied the authorization request
+
+	// The following fields are populated after Done == true
+	Subject  string
+	AMR      []string
+	AuthTime time.Time
+}
+
+func (r *DeviceAuthorizationState) GetAMR() []string {
+	return r.AMR
+}
+
+func (r *DeviceAuthorizationState) GetAudience() []string {
+	if !strs.Contains(r.Audience, r.ClientID) {
+		r.Audience = append(r.Audience, r.ClientID)
+	}
+	return r.Audience
+}
+
+func (r *DeviceAuthorizationState) GetAuthTime() time.Time {
+	return r.AuthTime
+}
+
+func (r *DeviceAuthorizationState) GetClientID() string {
+	return r.ClientID
+}
+
+func (r *DeviceAuthorizationState) GetScopes() []string {
+	return r.Scopes
+}
+
+func (r *DeviceAuthorizationState) GetSubject() string {
+	return r.Subject
+}
+
 func CheckDeviceAuthorizationState(ctx context.Context, clientID, deviceCode string, exchanger Exchanger) (*DeviceAuthorizationState, error) {
 	storage, err := assertDeviceStorage(exchanger.Storage())
 	if err != nil {
@@ -291,15 +313,32 @@ func CheckDeviceAuthorizationState(ctx context.Context, clientID, deviceCode str
 }
 
 func CreateDeviceTokenResponse(ctx context.Context, tokenRequest TokenRequest, creator TokenCreator, client Client) (*oidc.AccessTokenResponse, error) {
+	/* TODO(v4):
+	Change the TokenRequest argument type to *DeviceAuthorizationState.
+	Breaking change that can not be done for v3.
+	*/
+	ctx, span := tracer.Start(ctx, "CreateDeviceTokenResponse")
+	defer span.End()
+
 	accessToken, refreshToken, validity, err := CreateAccessToken(ctx, tokenRequest, client.AccessTokenType(), creator, client, "")
 	if err != nil {
 		return nil, err
 	}
 
-	return &oidc.AccessTokenResponse{
+	response := &oidc.AccessTokenResponse{
 		AccessToken:  accessToken,
 		RefreshToken: refreshToken,
 		TokenType:    oidc.BearerToken,
 		ExpiresIn:    uint64(validity.Seconds()),
-	}, nil
+	}
+
+	// TODO(v4): remove type assertion
+	if idTokenRequest, ok := tokenRequest.(IDTokenRequest); ok && strs.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
+		}
+	}
+
+	return response, nil
 }
diff --git a/pkg/op/discovery.go b/pkg/op/discovery.go
index 8251261..6af1674 100644
--- a/pkg/op/discovery.go
+++ b/pkg/op/discovery.go
@@ -213,32 +213,12 @@ func AuthMethodsRevocationEndpoint(c Configuration) []oidc.AuthMethod {
 }
 
 func SupportedClaims(c Configuration) []string {
-	return []string{ // TODO: config
-		"sub",
-		"aud",
-		"exp",
-		"iat",
-		"iss",
-		"auth_time",
-		"nonce",
-		"acr",
-		"amr",
-		"c_hash",
-		"at_hash",
-		"act",
-		"scopes",
-		"client_id",
-		"azp",
-		"preferred_username",
-		"name",
-		"family_name",
-		"given_name",
-		"locale",
-		"email",
-		"email_verified",
-		"phone_number",
-		"phone_number_verified",
+	provider, ok := c.(*Provider)
+	if ok && provider.config.SupportedClaims != nil {
+		return provider.config.SupportedClaims
 	}
+
+	return DefaultSupportedClaims
 }
 
 func CodeChallengeMethods(c Configuration) []oidc.CodeChallengeMethod {
diff --git a/pkg/op/op.go b/pkg/op/op.go
index 939ebf8..fdc073c 100644
--- a/pkg/op/op.go
+++ b/pkg/op/op.go
@@ -45,6 +45,33 @@ var (
 		DeviceAuthorization: NewEndpoint(defaultDeviceAuthzEndpoint),
 	}
 
+	DefaultSupportedClaims = []string{
+		"sub",
+		"aud",
+		"exp",
+		"iat",
+		"iss",
+		"auth_time",
+		"nonce",
+		"acr",
+		"amr",
+		"c_hash",
+		"at_hash",
+		"act",
+		"scopes",
+		"client_id",
+		"azp",
+		"preferred_username",
+		"name",
+		"family_name",
+		"given_name",
+		"locale",
+		"email",
+		"email_verified",
+		"phone_number",
+		"phone_number_verified",
+	}
+
 	defaultCORSOptions = cors.Options{
 		AllowCredentials: true,
 		AllowedHeaders: []string{
@@ -146,6 +173,7 @@ type Config struct {
 	GrantTypeRefreshToken    bool
 	RequestObjectSupported   bool
 	SupportedUILocales       []language.Tag
+	SupportedClaims          []string
 	DeviceAuthorization      DeviceAuthorizationConfig
 }
 
diff --git a/pkg/op/server_legacy.go b/pkg/op/server_legacy.go
index a851a2a..114d431 100644
--- a/pkg/op/server_legacy.go
+++ b/pkg/op/server_legacy.go
@@ -291,7 +291,7 @@ func (s *LegacyServer) ClientCredentialsExchange(ctx context.Context, r *ClientR
 }
 
 func (s *LegacyServer) DeviceToken(ctx context.Context, r *ClientRequest[oidc.DeviceAccessTokenRequest]) (*Response, error) {
-	if !s.provider.GrantTypeClientCredentialsSupported() {
+	if !s.provider.GrantTypeDeviceCodeSupported() {
 		return nil, unimplementedGrantError(oidc.GrantTypeDeviceCode)
 	}
 	// use a limited context timeout shorter as the default
@@ -299,15 +299,10 @@ func (s *LegacyServer) DeviceToken(ctx context.Context, r *ClientRequest[oidc.De
 	ctx, cancel := context.WithTimeout(ctx, 4*time.Second)
 	defer cancel()
 
-	state, err := CheckDeviceAuthorizationState(ctx, r.Client.GetID(), r.Data.DeviceCode, s.provider)
+	tokenRequest, err := CheckDeviceAuthorizationState(ctx, r.Client.GetID(), r.Data.DeviceCode, s.provider)
 	if err != nil {
 		return nil, err
 	}
-	tokenRequest := &deviceAccessTokenRequest{
-		subject:  state.Subject,
-		audience: []string{r.Client.GetID()},
-		scopes:   state.Scopes,
-	}
 	resp, err := CreateDeviceTokenResponse(ctx, tokenRequest, s.provider, r.Client)
 	if err != nil {
 		return nil, err
diff --git a/pkg/op/storage.go b/pkg/op/storage.go
index d083a31..a1a00ed 100644
--- a/pkg/op/storage.go
+++ b/pkg/op/storage.go
@@ -168,15 +168,6 @@ type EndSessionRequest struct {
 
 var ErrDuplicateUserCode = errors.New("user code already exists")
 
-type DeviceAuthorizationState struct {
-	ClientID string
-	Scopes   []string
-	Expires  time.Time
-	Done     bool
-	Subject  string
-	Denied   bool
-}
-
 type DeviceAuthorizationStorage interface {
 	// StoreDeviceAuthorizationRequest stores a new device authorization request in the database.
 	// User code will be used by the user to complete the login flow and must be unique.
diff --git a/pkg/op/token.go b/pkg/op/token.go
index 63a01a6..83889f0 100644
--- a/pkg/op/token.go
+++ b/pkg/op/token.go
@@ -84,6 +84,8 @@ func needsRefreshToken(tokenRequest TokenRequest, client AccessTokenClient) bool
 		return req.GetRequestedTokenType() == oidc.RefreshTokenType
 	case RefreshTokenRequest:
 		return true
+	case *DeviceAuthorizationState:
+		return strings.Contains(req.GetScopes(), oidc.ScopeOfflineAccess) && ValidateGrantType(client, oidc.GrantTypeRefreshToken)
 	default:
 		return false
 	}


Test patch:
diff --git a/pkg/op/device_test.go b/pkg/op/device_test.go
index f5452f9..570b943 100644
--- a/pkg/op/device_test.go
+++ b/pkg/op/device_test.go
@@ -453,3 +453,96 @@ func TestCheckDeviceAuthorizationState(t *testing.T) {
 		})
 	}
 }
+
+func TestCreateDeviceTokenResponse(t *testing.T) {
+	tests := []struct {
+		name             string
+		tokenRequest     op.TokenRequest
+		wantAccessToken  bool
+		wantRefreshToken bool
+		wantIDToken      bool
+		wantErr          bool
+	}{
+		{
+			name: "access token",
+			tokenRequest: &op.DeviceAuthorizationState{
+				ClientID: "client1",
+				Subject:  "id1",
+				AMR:      []string{"password"},
+				AuthTime: time.Now(),
+			},
+			wantAccessToken: true,
+		},
+		{
+			name: "access and refresh tokens",
+			tokenRequest: &op.DeviceAuthorizationState{
+				ClientID: "client1",
+				Subject:  "id1",
+				AMR:      []string{"password"},
+				AuthTime: time.Now(),
+				Scopes:   []string{oidc.ScopeOfflineAccess},
+			},
+			wantAccessToken:  true,
+			wantRefreshToken: true,
+		},
+		{
+			name: "access and id token",
+			tokenRequest: &op.DeviceAuthorizationState{
+				ClientID: "client1",
+				Subject:  "id1",
+				AMR:      []string{"password"},
+				AuthTime: time.Now(),
+				Scopes:   []string{oidc.ScopeOpenID},
+			},
+			wantAccessToken: true,
+			wantIDToken:     true,
+		},
+		{
+			name: "access, refresh and id token",
+			tokenRequest: &op.DeviceAuthorizationState{
+				ClientID: "client1",
+				Subject:  "id1",
+				AMR:      []string{"password"},
+				AuthTime: time.Now(),
+				Scopes:   []string{oidc.ScopeOfflineAccess, oidc.ScopeOpenID},
+			},
+			wantAccessToken:  true,
+			wantRefreshToken: true,
+			wantIDToken:      true,
+		},
+		{
+			name: "id token creation error",
+			tokenRequest: &op.DeviceAuthorizationState{
+				ClientID: "client1",
+				Subject:  "foobar",
+				AMR:      []string{"password"},
+				AuthTime: time.Now(),
+				Scopes:   []string{oidc.ScopeOfflineAccess, oidc.ScopeOpenID},
+			},
+			wantErr: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			client, err := testProvider.Storage().GetClientByClientID(context.Background(), "native")
+			require.NoError(t, err)
+
+			got, err := op.CreateDeviceTokenResponse(context.Background(), tt.tokenRequest, testProvider, client)
+			if tt.wantErr {
+				require.Error(t, err)
+				return
+			}
+			require.NoError(t, err)
+			assert.InDelta(t, 300, got.ExpiresIn, 2)
+			if tt.wantAccessToken {
+				assert.NotEmpty(t, got.AccessToken, "access token")
+			}
+			if tt.wantRefreshToken {
+				assert.NotEmpty(t, got.RefreshToken, "refresh token")
+			}
+			if tt.wantIDToken {
+				assert.NotEmpty(t, got.IDToken, "id token")
+			}
+		})
+	}
+}
diff --git a/pkg/op/op_test.go b/pkg/op/op_test.go
index 062fcfe..f97f666 100644
--- a/pkg/op/op_test.go
+++ b/pkg/op/op_test.go
@@ -30,6 +30,7 @@ var (
 		AuthMethodPrivateKeyJWT:  true,
 		GrantTypeRefreshToken:    true,
 		RequestObjectSupported:   true,
+		SupportedClaims:          op.DefaultSupportedClaims,
 		SupportedUILocales:       []language.Tag{language.English},
 		DeviceAuthorization: op.DeviceAuthorizationConfig{
 			Lifetime:     5 * time.Minute,