feat(api): allow Device Authorization Grant using custom login UI (#9387)

# Which Problems Are Solved

The OAuth2 Device Authorization Grant could not yet been handled through
the new login UI, resp. using the session API.
This PR adds the ability for the login UI to get the required
information to display the user and handle their decision (approve with
authorization or deny) using the OIDC Service API.

# How the Problems Are Solved

- Added a `GetDeviceAuthorizationRequest` endpoint, which allows getting
the `id`, `client_id`, `scope`, `app_name` and `project_name` of the
device authorization request
- Added a `AuthorizeOrDenyDeviceAuthorization` endpoint, which allows to
approve/authorize with the session information or deny the request. The
identification of the request is done by the `device_authorization_id` /
`id` returned in the previous request.
- To prevent leaking the `device_code` to the UI, but still having an
easy reference, it's encrypted and returned as `id`, resp. decrypted
when used.
- Fixed returned error types for device token responses on token
endpoint:
- Explicitly return `access_denied` (without internal error) when user
denied the request
  - Default to `invalid_grant` instead of `access_denied`
- Explicitly check on initial state when approving the reqeust
- Properly handle done case (also relates to initial check) 
- Documented the flow and handling in custom UIs (according to OIDC /
SAML)

# Additional Changes

- fixed some typos and punctuation in the corresponding OIDC / SAML
guides.
- added some missing translations for auth and saml request

# Additional Context

- closes #6239

---------

Co-authored-by: Tim Möhlmann <tim+github@zitadel.com>
This commit is contained in:
Livio Spring 2025-02-25 07:33:13 +01:00 committed by GitHub
parent f2e82d57ac
commit 911200aa9b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1210 additions and 35 deletions

View File

@ -560,7 +560,7 @@ func startAPIs(
if err := apis.RegisterService(ctx, oidc_v2beta.CreateServer(commands, queries, oidcServer, config.ExternalSecure)); err != nil {
return nil, err
}
if err := apis.RegisterService(ctx, oidc_v2.CreateServer(commands, queries, oidcServer, config.ExternalSecure)); err != nil {
if err := apis.RegisterService(ctx, oidc_v2.CreateServer(commands, queries, oidcServer, config.ExternalSecure, keys.OIDC)); err != nil {
return nil, err
}
// After SAML provider so that the callback endpoint can be used

View File

@ -0,0 +1,165 @@
---
title: Support for the Device Authorization Grant in a Custom Login UI
sidebar_label: Device Authorization
---
In case one of your applications requires the [OAuth2 Device Authorization Grant](/docs/guides/integrate/login/oidc/device-authorization) this guide will show you how to implement
this in your application as well as the custom login UI.
The following flow shows you the different components you need to enable OAuth2 Device Authorization Grant for your login.
![Device Auth Flow](/img/guides/login-ui/device-auth-flow.png)
1. Your application makes a device authorization request to your login UI
2. The login UI proxies the request to ZITADEL.
3. ZITADEL parses the request and does what it needs to interpret certain parameters (e.g., organization scope, etc.)
4. ZITADEL returns the device authorization response
5. Your application presents the `user_code` and `verification_uri` or maybe even renders a QR code with the `verification_uri_complete` for the user to scan
6. Your application starts a polling mechanism to check if the user has approved the device authorization request on the token endpoint
7. When the user opens the browser at the verification_uri, he can enter the user_code, or it's automatically filled in, if they scan the QR code
8. Request the device authorization request from the ZITADEL API using the user_code
9. Your login UI allows to approve or deny the device request
10. In case they approved, authenticate the user in your login UI by creating and updating a session with all the checks you need.
11. Inform ZITADEL about the decision:
1. Authorize the device authorization request by sending the session and the previously retrieved id of the device authorization request to the ZITADEL API
2. In case they denied, deny the device authorization from the ZITADEL API using the previously retrieved id of the device authorization request
12. Notify the user that they can close the window now and return to the application.
13. Your applications request to the token endpoint now receives the tokens or an error if the user denied the request.
## Example
Let's assume you host your login UI on the following URL:
```
https://login.example.com
```
## Device Authorization Request
A user opens your application and is unauthenticated, the application will create the following request:
```HTTP
POST /oauth/v2/device_authorization HTTP/1.1
Host: login.example.com
Content-type: application/x-www-form-urlencoded
client_id=170086824411201793&
scope=openid%20email%20profile
```
The request includes all the relevant information for the OAuth2 Device Authorization Grant and in this example we also have some scopes for the user.
You now have to proxy the auth request from your own UI to the device authorization Endpoint of ZITADEL.
For more information, see [OIDC Proxy](./typescript-repo#oidc-proxy) for the necessary headers.
:::note
The version and the optional custom URI for the available login UI is configurable under the application settings.
:::
The endpoint will return the device authorization response:
```json
{
"device_code": "0jbAZbU3ClK-Mkt0li4U1A",
"user_code": "FWRK-JGWK",
"verification_uri": "https://login.example.com/device",
"verification_uri_complete": "https://login.example.com/device?user_code=FWRK-JGWK",
"expires_in": 300,
"interval": 5
}
```
The device presents the `user_code` and `verification_uri` or maybe even render a QR code with the `verification_uri_complete` for the user to scan.
Your login will have to provide a page on the `verification_uri` where the user can enter the `user_code`, or it's automatically filled in, if they scan the QR code.
### Get the Device Authorization Request by User Code
With the user_code entered by the user you will now be able to get the information of the device authorization request.
[Get Device Authorization Request Documentation](/docs/apis/resources/oidc_service_v2/oidc-service-get-device-authorization-request)
```bash
curl --request GET \
--url https://$ZITADEL_DOMAIN/v2/oidc/device_authorization/FWRK-JGWK \
--header 'Authorization: Bearer '"$TOKEN"''
```
Response Example:
```json
{
"deviceAuthorizationRequest": {
"id": "XzNejv6NxqVU8Qur5uxEh7f_Wi1p0qUu4PJTJ6JUIx0xtJ2uqmU",
"clientId": "170086824411201793",
"scope": [
"openid",
"profile"
],
"appName": "TV App",
"projectName": "My Project"
}
}
```
Present the user with the information of the device authorization request and allow them to approve or deny the request.
### Perform Login
After you have initialized the OIDC flow you can implement the login.
Implement all the steps you like the user the go trough by [creating](/docs/apis/resources/session_service_v2/session-service-create-session) and [updating](/docs/apis/resources/session_service/session-service-set-session) the user-session.
Read the following resources for more information about the different checks:
- [Username and Password](./username-password)
- [External Identity Provider](./external-login)
- [Passkeys](./passkey)
- [Multi-Factor](./mfa)
### Authorize the Device Authorization Request
To finalize the auth request and connect an existing user session with it, you have to update the auth request with the session token.
On the create and update user session request you will always get a session token in the response.
The latest session token has to be sent to the following request:
Read more about the [Authorize or Deny Device Authorization Request Documentation](/docs/apis/resources/oidc_service_v2/oidc-service-authorize-device-authorization)
Make sure that the authorization header is from an account which is permitted to finalize the Auth Request through the `IAM_LOGIN_CLIENT` role.
```bash
curl --request POST \
--url $ZITADEL_DOMAIN/v2/oidc/device_authorization/XzNejv6NxqVU8Qur5uxEh7f_Wi1p0qUu4PJTJ6JUIx0xtJ2uqmU \
--header 'Accept: application/json' \
--header 'Authorization: Bearer '"$TOKEN"''\
--header 'Content-Type: application/json' \
--data '{
"session": {
"sessionId": "225307381909694508",
"sessionToken": "7N5kQCvC4jIf2OuBjwfyWSX2FUKbQqg4iG3uWT-TBngMhlS9miGUwpyUaN0HJ8OcbSzk4QHZy_Bvvv"
}
}'
```
If you don't get any error back, the request succeeded, and you can notify the user that they can close the window now and return to the application.
### Deny the Device Authorization Request
If the user denies the device authorization request, you can deny the request by sending the following request:
```bash
curl --request POST \
--url $ZITADEL_DOMAIN/v2/oidc/device_authorization/ \
--header 'Accept: application/json' \
--header 'Authorization: Bearer '"$TOKEN"''\
--header 'Content-Type: application/json' \
--data '{
"deny": {}
}'
```
If you don't get any error back, the request succeeded, and you can notify the user that they can close the window now and return to the application.
### Device Authorization Endpoints
All OAuth2 Device Authorization Grant endpoints are provided by ZITADEL. In your login UI you just have to proxy them through and send them directly to the backend.
These endpoints are:
- Well-known
- Device Authorization Endpoint
- Token
Additionally, we recommend you to proxy all the other [OIDC relevant endpoints](./oidc-standard#endpoints).

View File

@ -56,7 +56,7 @@ With the ID from the redirect before you will now be able to get the information
```bash
curl --request GET \
--url https://$ZITADEL_DOMAIN/v2/oidc/auth_requests/V2_224908753244265546 \
--header 'Authorization: Bearer '"$TOKEN"''\
--header 'Authorization: Bearer '"$TOKEN"''
```
Response Example:
@ -90,7 +90,7 @@ Read the following resources for more information about the different checks:
### Finalize Auth Request
To finalize the auth request and connect an existing user session with it you have to update the auth request with the session token.
To finalize the auth request and connect an existing user session with it, you have to update the auth request with the session token.
On the create and update user session request you will always get a session token in the response.
The latest session token has to be sent to the following request:
@ -128,7 +128,7 @@ Example Response:
### OIDC Endpoints
All OIDC relevant endpoints are provided by ZITADEL. In you login UI you just have to proxy them through and send them directly to the backend.
All OIDC relevant endpoints are provided by ZITADEL. In your login UI you just have to proxy them through and send them directly to the backend.
These are endpoints like:
- Userinfo

View File

@ -56,7 +56,7 @@ With the ID from the redirect before you will now be able to get the information
```bash
curl --request GET \
--url https://$ZITADEL_DOMAIN/v2/saml/saml_requests/V2_224908753244265546 \
--header 'Authorization: Bearer '"$TOKEN"''\
--header 'Authorization: Bearer '"$TOKEN"''
```
Response Example:
@ -87,7 +87,7 @@ Read the following resources for more information about the different checks:
### Finalize SAML Request
To finalize the SAML request and connect an existing user session with it you have to update the SAML Request with the session token.
To finalize the SAML request and connect an existing user session with it, you have to update the SAML Request with the session token.
On the create and update user session request you will always get a session token in the response.
The latest session token has to be sent to the following request:

View File

@ -328,6 +328,7 @@ module.exports = {
"guides/integrate/login-ui/logout",
"guides/integrate/login-ui/oidc-standard",
"guides/integrate/login-ui/saml-standard",
"guides/integrate/login-ui/device-auth",
"guides/integrate/login-ui/typescript-repo",
],
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@ -13,6 +13,7 @@ import (
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/oidc/v3/pkg/oidc"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/integration"
@ -636,6 +637,230 @@ func TestServer_CreateCallback_Permission(t *testing.T) {
}
}
func TestServer_GetDeviceAuthorizationRequest(t *testing.T) {
project, err := Instance.CreateProject(CTX)
require.NoError(t, err)
client, err := Instance.CreateOIDCClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, app.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE)
require.NoError(t, err)
tests := []struct {
name string
dep func() (*oidc.DeviceAuthorizationResponse, error)
ctx context.Context
want *oidc.DeviceAuthorizationResponse
wantErr bool
}{
{
name: "Not found",
dep: func() (*oidc.DeviceAuthorizationResponse, error) {
return &oidc.DeviceAuthorizationResponse{
UserCode: "notFound",
}, nil
},
ctx: CTX,
wantErr: true,
},
{
name: "success",
dep: func() (*oidc.DeviceAuthorizationResponse, error) {
return Instance.CreateDeviceAuthorizationRequest(CTX, client.GetClientId(), "openid")
},
ctx: CTX,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
deviceAuth, err := tt.dep()
require.NoError(t, err)
got, err := Client.GetDeviceAuthorizationRequest(tt.ctx, &oidc_pb.GetDeviceAuthorizationRequestRequest{
UserCode: deviceAuth.UserCode,
})
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
authRequest := got.GetDeviceAuthorizationRequest()
assert.NotNil(t, authRequest)
assert.NotEmpty(t, authRequest.GetId())
assert.Equal(t, client.GetClientId(), authRequest.GetClientId())
assert.Contains(t, authRequest.GetScope(), "openid")
assert.NotEmpty(t, authRequest.GetAppName())
assert.NotEmpty(t, authRequest.GetProjectName())
})
}
}
func TestServer_AuthorizeOrDenyDeviceAuthorization(t *testing.T) {
project, err := Instance.CreateProject(CTX)
require.NoError(t, err)
client, err := Instance.CreateOIDCClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, app.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE)
require.NoError(t, err)
sessionResp := createSession(t, CTX, Instance.Users[integration.UserTypeOrgOwner].ID)
tests := []struct {
name string
ctx context.Context
req *oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest
AuthError string
want *oidc_pb.AuthorizeOrDenyDeviceAuthorizationResponse
wantURL *url.URL
wantErr bool
}{
{
name: "Not found",
ctx: CTX,
req: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest{
DeviceAuthorizationId: "123",
Decision: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Session{
Session: &oidc_pb.Session{
SessionId: sessionResp.GetSessionId(),
SessionToken: sessionResp.GetSessionToken(),
},
},
},
wantErr: true,
},
{
name: "session not found",
ctx: CTX,
req: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest{
DeviceAuthorizationId: func() string {
req, err := Instance.CreateDeviceAuthorizationRequest(CTX, client.GetClientId(), "openid")
require.NoError(t, err)
var id string
assert.EventuallyWithT(t, func(collectT *assert.CollectT) {
resp, err := Instance.Client.OIDCv2.GetDeviceAuthorizationRequest(CTX, &oidc_pb.GetDeviceAuthorizationRequestRequest{
UserCode: req.UserCode,
})
assert.NoError(t, err)
id = resp.GetDeviceAuthorizationRequest().GetId()
}, 5*time.Second, 100*time.Millisecond)
return id
}(),
Decision: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Session{
Session: &oidc_pb.Session{
SessionId: "foo",
SessionToken: "bar",
},
},
},
wantErr: true,
},
{
name: "session token invalid",
ctx: CTX,
req: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest{
DeviceAuthorizationId: func() string {
req, err := Instance.CreateDeviceAuthorizationRequest(CTX, client.GetClientId(), "openid")
require.NoError(t, err)
var id string
assert.EventuallyWithT(t, func(collectT *assert.CollectT) {
resp, err := Instance.Client.OIDCv2.GetDeviceAuthorizationRequest(CTX, &oidc_pb.GetDeviceAuthorizationRequestRequest{
UserCode: req.UserCode,
})
assert.NoError(collectT, err)
id = resp.GetDeviceAuthorizationRequest().GetId()
}, 5*time.Second, 100*time.Millisecond)
return id
}(),
Decision: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Session{
Session: &oidc_pb.Session{
SessionId: sessionResp.GetSessionId(),
SessionToken: "bar",
},
},
},
wantErr: true,
},
{
name: "deny device authorization",
ctx: CTX,
req: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest{
DeviceAuthorizationId: func() string {
req, err := Instance.CreateDeviceAuthorizationRequest(CTX, client.GetClientId(), "openid")
require.NoError(t, err)
var id string
assert.EventuallyWithT(t, func(collectT *assert.CollectT) {
resp, err := Instance.Client.OIDCv2.GetDeviceAuthorizationRequest(CTX, &oidc_pb.GetDeviceAuthorizationRequestRequest{
UserCode: req.UserCode,
})
assert.NoError(collectT, err)
id = resp.GetDeviceAuthorizationRequest().GetId()
}, 5*time.Second, 100*time.Millisecond)
return id
}(),
Decision: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Deny{},
},
want: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationResponse{},
wantErr: false,
},
{
name: "authorize, no permission, error",
ctx: CTX,
req: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest{
DeviceAuthorizationId: func() string {
req, err := Instance.CreateDeviceAuthorizationRequest(CTX, client.GetClientId(), "openid")
require.NoError(t, err)
var id string
assert.EventuallyWithT(t, func(collectT *assert.CollectT) {
resp, err := Instance.Client.OIDCv2.GetDeviceAuthorizationRequest(CTX, &oidc_pb.GetDeviceAuthorizationRequestRequest{
UserCode: req.UserCode,
})
assert.NoError(collectT, err)
id = resp.GetDeviceAuthorizationRequest().GetId()
}, 5*time.Second, 100*time.Millisecond)
return id
}(),
Decision: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Session{
Session: &oidc_pb.Session{
SessionId: sessionResp.GetSessionId(),
SessionToken: sessionResp.GetSessionToken(),
},
},
},
wantErr: true,
},
{
name: "authorize, with permission",
ctx: CTXLoginClient,
req: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest{
DeviceAuthorizationId: func() string {
req, err := Instance.CreateDeviceAuthorizationRequest(CTX, client.GetClientId(), "openid")
require.NoError(t, err)
var id string
assert.EventuallyWithT(t, func(collectT *assert.CollectT) {
resp, err := Instance.Client.OIDCv2.GetDeviceAuthorizationRequest(CTX, &oidc_pb.GetDeviceAuthorizationRequestRequest{
UserCode: req.UserCode,
})
assert.NoError(collectT, err)
id = resp.GetDeviceAuthorizationRequest().GetId()
}, 5*time.Second, 100*time.Millisecond)
return id
}(),
Decision: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Session{
Session: &oidc_pb.Session{
SessionId: sessionResp.GetSessionId(),
SessionToken: sessionResp.GetSessionToken(),
},
},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := Client.AuthorizeOrDenyDeviceAuthorization(tt.ctx, tt.req)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func createSession(t *testing.T, ctx context.Context, userID string) *session.CreateSessionResponse {
sessionResp, err := Instance.Client.SessionV2.CreateSession(ctx, &session.CreateSessionRequest{
Checks: &session.Checks{

View File

@ -2,6 +2,7 @@ package oidc
import (
"context"
"encoding/base64"
"github.com/zitadel/logging"
"github.com/zitadel/oidc/v3/pkg/op"
@ -28,6 +29,54 @@ func (s *Server) GetAuthRequest(ctx context.Context, req *oidc_pb.GetAuthRequest
}, nil
}
func (s *Server) CreateCallback(ctx context.Context, req *oidc_pb.CreateCallbackRequest) (*oidc_pb.CreateCallbackResponse, error) {
switch v := req.GetCallbackKind().(type) {
case *oidc_pb.CreateCallbackRequest_Error:
return s.failAuthRequest(ctx, req.GetAuthRequestId(), v.Error)
case *oidc_pb.CreateCallbackRequest_Session:
return s.linkSessionToAuthRequest(ctx, req.GetAuthRequestId(), v.Session)
default:
return nil, zerrors.ThrowUnimplementedf(nil, "OIDCv2-zee7A", "verification oneOf %T in method CreateCallback not implemented", v)
}
}
func (s *Server) GetDeviceAuthorizationRequest(ctx context.Context, req *oidc_pb.GetDeviceAuthorizationRequestRequest) (*oidc_pb.GetDeviceAuthorizationRequestResponse, error) {
deviceRequest, err := s.query.DeviceAuthRequestByUserCode(ctx, req.GetUserCode())
if err != nil {
return nil, err
}
encrypted, err := s.encryption.Encrypt([]byte(deviceRequest.DeviceCode))
if err != nil {
return nil, err
}
return &oidc_pb.GetDeviceAuthorizationRequestResponse{
DeviceAuthorizationRequest: &oidc_pb.DeviceAuthorizationRequest{
Id: base64.RawURLEncoding.EncodeToString(encrypted),
ClientId: deviceRequest.ClientID,
Scope: deviceRequest.Scopes,
AppName: deviceRequest.AppName,
ProjectName: deviceRequest.ProjectName,
},
}, nil
}
func (s *Server) AuthorizeOrDenyDeviceAuthorization(ctx context.Context, req *oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest) (*oidc_pb.AuthorizeOrDenyDeviceAuthorizationResponse, error) {
deviceCode, err := s.deviceCodeFromID(req.GetDeviceAuthorizationId())
if err != nil {
return nil, err
}
switch req.GetDecision().(type) {
case *oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Session:
_, err = s.command.ApproveDeviceAuthWithSession(ctx, deviceCode, req.GetSession().GetSessionId(), req.GetSession().GetSessionToken())
case *oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Deny:
_, err = s.command.CancelDeviceAuth(ctx, deviceCode, domain.DeviceAuthCanceledDenied)
}
if err != nil {
return nil, err
}
return &oidc_pb.AuthorizeOrDenyDeviceAuthorizationResponse{}, nil
}
func authRequestToPb(a *query.AuthRequest) *oidc_pb.AuthRequest {
pba := &oidc_pb.AuthRequest{
Id: a.ID,
@ -87,17 +136,6 @@ func (s *Server) checkPermission(ctx context.Context, clientID string, userID st
return nil
}
func (s *Server) CreateCallback(ctx context.Context, req *oidc_pb.CreateCallbackRequest) (*oidc_pb.CreateCallbackResponse, error) {
switch v := req.GetCallbackKind().(type) {
case *oidc_pb.CreateCallbackRequest_Error:
return s.failAuthRequest(ctx, req.GetAuthRequestId(), v.Error)
case *oidc_pb.CreateCallbackRequest_Session:
return s.linkSessionToAuthRequest(ctx, req.GetAuthRequestId(), v.Session)
default:
return nil, zerrors.ThrowUnimplementedf(nil, "OIDCv2-zee7A", "verification oneOf %T in method CreateCallback not implemented", v)
}
}
func (s *Server) failAuthRequest(ctx context.Context, authRequestID string, ae *oidc_pb.AuthorizationError) (*oidc_pb.CreateCallbackResponse, error) {
details, aar, err := s.command.FailAuthRequest(ctx, authRequestID, errorReasonToDomain(ae.GetError()))
if err != nil {
@ -215,3 +253,11 @@ func errorReasonToOIDC(reason oidc_pb.ErrorReason) string {
return "server_error"
}
}
func (s *Server) deviceCodeFromID(deviceAuthID string) (string, error) {
decoded, err := base64.RawURLEncoding.DecodeString(deviceAuthID)
if err != nil {
return "", err
}
return s.encryption.DecryptString(decoded, s.encryption.EncryptionKeyID())
}

View File

@ -7,6 +7,7 @@ import (
"github.com/zitadel/zitadel/internal/api/grpc/server"
"github.com/zitadel/zitadel/internal/api/oidc"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/query"
oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2"
)
@ -20,6 +21,7 @@ type Server struct {
op *oidc.Server
externalSecure bool
encryption crypto.EncryptionAlgorithm
}
type Config struct{}
@ -29,12 +31,14 @@ func CreateServer(
query *query.Queries,
op *oidc.Server,
externalSecure bool,
encryption crypto.EncryptionAlgorithm,
) *Server {
return &Server{
command: command,
query: query,
op: op,
externalSecure: externalSecure,
encryption: encryption,
}
}

View File

@ -0,0 +1,127 @@
//go:build integration
package oidc_test
import (
"context"
"slices"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/oidc/v3/pkg/client/rp"
"github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/pkg/grpc/app"
"github.com/zitadel/zitadel/pkg/grpc/auth"
oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2"
)
func TestServer_DeviceAuth(t *testing.T) {
project, err := Instance.CreateProject(CTX)
require.NoError(t, err)
client, err := Instance.CreateOIDCClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, app.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE)
require.NoError(t, err)
tests := []struct {
name string
scope []string
decision func(t *testing.T, id string)
wantErr error
}{
{
name: "authorized",
scope: []string{},
decision: func(t *testing.T, id string) {
sessionID, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
_, err = Instance.Client.OIDCv2.AuthorizeOrDenyDeviceAuthorization(CTXLOGIN, &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest{
DeviceAuthorizationId: id,
Decision: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Session{
Session: &oidc_pb.Session{
SessionId: sessionID,
SessionToken: sessionToken,
},
},
})
require.NoError(t, err)
},
},
{
name: "authorized, with ZITADEL",
scope: []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, domain.ProjectScopeZITADEL},
decision: func(t *testing.T, id string) {
sessionID, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
_, err = Instance.Client.OIDCv2.AuthorizeOrDenyDeviceAuthorization(CTXLOGIN, &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest{
DeviceAuthorizationId: id,
Decision: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Session{
Session: &oidc_pb.Session{
SessionId: sessionID,
SessionToken: sessionToken,
},
},
})
require.NoError(t, err)
},
},
{
name: "denied",
scope: []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, domain.ProjectScopeZITADEL},
decision: func(t *testing.T, id string) {
_, err = Instance.Client.OIDCv2.AuthorizeOrDenyDeviceAuthorization(CTXLOGIN, &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest{
DeviceAuthorizationId: id,
Decision: &oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Deny{
Deny: &oidc_pb.Deny{},
},
})
require.NoError(t, err)
},
wantErr: oidc.ErrAccessDenied(),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
provider, err := rp.NewRelyingPartyOIDC(CTX, Instance.OIDCIssuer(), client.GetClientId(), "", "", tt.scope)
require.NoError(t, err)
deviceAuthorization, err := rp.DeviceAuthorization(CTX, tt.scope, provider, nil)
require.NoError(t, err)
relyingPartyDone := make(chan struct{})
go func() {
ctx, cancel := context.WithTimeout(CTX, 1*time.Minute)
defer func() {
cancel()
relyingPartyDone <- struct{}{}
}()
tokens, err := rp.DeviceAccessToken(ctx, deviceAuthorization.DeviceCode, time.Duration(deviceAuthorization.Interval)*time.Second, provider)
require.ErrorIs(t, err, tt.wantErr)
if tokens == nil {
return
}
_, err = Instance.Client.Auth.GetMyUser(integration.WithAuthorizationToken(CTX, tokens.AccessToken), &auth.GetMyUserRequest{})
if slices.Contains(tt.scope, domain.ProjectScopeZITADEL) {
require.NoError(t, err)
} else {
require.Error(t, err)
}
}()
var req *oidc_pb.GetDeviceAuthorizationRequestResponse
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute)
assert.EventuallyWithT(t, func(collectT *assert.CollectT) {
req, err = Instance.Client.OIDCv2.GetDeviceAuthorizationRequest(CTX, &oidc_pb.GetDeviceAuthorizationRequestRequest{
UserCode: deviceAuthorization.UserCode,
})
assert.NoError(collectT, err)
}, retryDuration, tick)
tt.decision(t, req.GetDeviceAuthorizationRequest().GetId())
<-relyingPartyDone
})
}
}

View File

@ -42,6 +42,9 @@ func (s *Server) DeviceToken(ctx context.Context, r *op.ClientRequest[oidc.Devic
if state == domain.DeviceAuthStateExpired {
return nil, oidc.ErrExpiredDeviceCode()
}
if state == domain.DeviceAuthStateDenied {
return nil, oidc.ErrAccessDenied()
}
}
return nil, oidc.ErrAccessDenied().WithParent(err).WithReturnParentToClient(authz.GetFeatures(ctx).DebugOIDCParentError)
return nil, oidc.ErrInvalidGrant().WithParent(err).WithReturnParentToClient(authz.GetFeatures(ctx).DebugOIDCParentError)
}

View File

@ -59,6 +59,9 @@ func (c *Commands) ApproveDeviceAuth(
if !model.State.Exists() {
return nil, zerrors.ThrowNotFound(nil, "COMMAND-Hief9", "Errors.DeviceAuth.NotFound")
}
if model.State != domain.DeviceAuthStateInitiated {
return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-GEJL3", "Errors.DeviceAuth.AlreadyHandled")
}
pushedEvents, err := c.eventstore.Push(ctx, deviceauth.NewApprovedEvent(ctx, model.aggregate, userID, userOrgID, authMethods, authTime, preferredLanguage, userAgent, sessionID))
if err != nil {
return nil, err
@ -71,6 +74,60 @@ func (c *Commands) ApproveDeviceAuth(
return writeModelToObjectDetails(&model.WriteModel), nil
}
func (c *Commands) ApproveDeviceAuthWithSession(
ctx context.Context,
deviceCode,
sessionID,
sessionToken string,
) (*domain.ObjectDetails, error) {
model, err := c.getDeviceAuthWriteModelByDeviceCode(ctx, deviceCode)
if err != nil {
return nil, err
}
if !model.State.Exists() {
return nil, zerrors.ThrowNotFound(nil, "COMMAND-D2hf2", "Errors.DeviceAuth.NotFound")
}
if model.State != domain.DeviceAuthStateInitiated {
return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-D30Jf", "Errors.DeviceAuth.AlreadyHandled")
}
if err := c.checkPermission(ctx, domain.PermissionSessionLink, model.ResourceOwner, ""); err != nil {
return nil, err
}
sessionWriteModel := NewSessionWriteModel(sessionID, authz.GetInstance(ctx).InstanceID())
err = c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel)
if err != nil {
return nil, err
}
if err = sessionWriteModel.CheckIsActive(); err != nil {
return nil, err
}
if err := c.sessionTokenVerifier(ctx, sessionToken, sessionWriteModel.AggregateID, sessionWriteModel.TokenID); err != nil {
return nil, err
}
pushedEvents, err := c.eventstore.Push(ctx, deviceauth.NewApprovedEvent(
ctx,
model.aggregate,
sessionWriteModel.UserID,
sessionWriteModel.UserResourceOwner,
sessionWriteModel.AuthMethodTypes(),
sessionWriteModel.AuthenticationTime(),
sessionWriteModel.PreferredLanguage,
sessionWriteModel.UserAgent,
sessionID,
))
if err != nil {
return nil, err
}
err = AppendAndReduce(model, pushedEvents...)
if err != nil {
return nil, err
}
return writeModelToObjectDetails(&model.WriteModel), nil
}
func (c *Commands) CancelDeviceAuth(ctx context.Context, id string, reason domain.DeviceAuthCanceled) (*domain.ObjectDetails, error) {
model, err := c.getDeviceAuthWriteModelByDeviceCode(ctx, id)
if err != nil {

View File

@ -82,6 +82,7 @@ func (m *DeviceAuthWriteModel) Query() *eventstore.SearchQueryBuilder {
deviceauth.AddedEventType,
deviceauth.ApprovedEventType,
deviceauth.CanceledEventType,
deviceauth.DoneEventType,
).
Builder()
}

View File

@ -23,6 +23,7 @@ import (
"github.com/zitadel/zitadel/internal/id/mock"
"github.com/zitadel/zitadel/internal/repository/deviceauth"
"github.com/zitadel/zitadel/internal/repository/oidcsession"
"github.com/zitadel/zitadel/internal/repository/session"
"github.com/zitadel/zitadel/internal/repository/user"
"github.com/zitadel/zitadel/internal/zerrors"
)
@ -265,6 +266,310 @@ func TestCommands_ApproveDeviceAuth(t *testing.T) {
}
}
func TestCommands_ApproveDeviceAuthFromSession(t *testing.T) {
ctx := authz.WithInstanceID(context.Background(), "instance1")
now := time.Now()
pushErr := errors.New("pushErr")
type fields struct {
eventstore func(*testing.T) *eventstore.Eventstore
tokenVerifier func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error)
checkPermission domain.PermissionCheck
}
type args struct {
ctx context.Context
deviceCode string
sessionID string
sessionToken string
}
tests := []struct {
name string
fields fields
args args
wantDetails *domain.ObjectDetails
wantErr error
}{
{
name: "not found error",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
},
args: args{
ctx,
"notfound",
"sessionID",
"sessionToken",
},
wantErr: zerrors.ThrowNotFound(nil, "COMMAND-D2hf2", "Errors.DeviceAuth.NotFound"),
},
{
name: "not initialized, error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusherWithInstanceID(
"instance1",
deviceauth.NewAddedEvent(
ctx,
deviceauth.NewAggregate("deviceCode", "instance1"),
"client_id", "deviceCode", "456", now,
[]string{"a", "b", "c"},
[]string{"projectID", "clientID"}, true,
),
),
eventFromEventPusherWithInstanceID(
"instance1",
deviceauth.NewCanceledEvent(
ctx,
deviceauth.NewAggregate("deviceCode", "instance1"),
domain.DeviceAuthCanceledDenied,
)),
),
),
},
args: args{
ctx,
"deviceCode",
"sessionID",
"sessionToken",
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-D30Jf", "Errors.DeviceAuth.AlreadyHandled"),
},
{
name: "missing permission, error",
fields: fields{
eventstore: expectEventstore(
expectFilter(eventFromEventPusherWithInstanceID(
"instance1",
deviceauth.NewAddedEvent(
ctx,
deviceauth.NewAggregate("deviceCode", "instance1"),
"client_id", "deviceCode", "456", now,
[]string{"a", "b", "c"},
[]string{"projectID", "clientID"}, true,
),
)),
),
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
ctx,
"deviceCode",
"sessionID",
"sessionToken",
},
wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
},
{
name: "session not active, error",
fields: fields{
eventstore: expectEventstore(
expectFilter(eventFromEventPusherWithInstanceID(
"instance1",
deviceauth.NewAddedEvent(
ctx,
deviceauth.NewAggregate("deviceCode", "instance1"),
"client_id", "deviceCode", "456", now,
[]string{"a", "b", "c"},
[]string{"projectID", "clientID"}, true,
),
)),
expectFilter(),
),
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
ctx,
"deviceCode",
"sessionID",
"sessionToken",
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-Flk38", "Errors.Session.NotExisting"),
},
{
name: "invalid session token, error",
fields: fields{
eventstore: expectEventstore(
expectFilter(eventFromEventPusherWithInstanceID(
"instance1",
deviceauth.NewAddedEvent(
ctx,
deviceauth.NewAggregate("deviceCode", "instance1"),
"client_id", "deviceCode", "456", now,
[]string{"a", "b", "c"},
[]string{"projectID", "clientID"}, true,
),
)),
expectFilter(eventFromEventPusherWithInstanceID(
"instance1",
session.NewAddedEvent(ctx,
&session.NewAggregate("sessionID", "instance1").Aggregate,
&domain.UserAgent{
FingerprintID: gu.Ptr("fp1"),
IP: net.ParseIP("1.2.3.4"),
Description: gu.Ptr("firefox"),
Header: http.Header{"foo": []string{"bar"}},
},
)),
)),
tokenVerifier: newMockTokenVerifierInvalid(),
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
ctx,
"deviceCode",
"sessionID",
"invalidToken",
},
wantErr: zerrors.ThrowPermissionDenied(nil, "COMMAND-sGr42", "Errors.Session.Token.Invalid"),
},
{
name: "push error",
fields: fields{
eventstore: expectEventstore(
expectFilter(eventFromEventPusherWithInstanceID(
"instance1",
deviceauth.NewAddedEvent(
ctx,
deviceauth.NewAggregate("deviceCode", "instance1"),
"client_id", "deviceCode", "456", now,
[]string{"a", "b", "c"},
[]string{"projectID", "clientID"}, true,
),
)),
expectFilter(
eventFromEventPusher(
session.NewAddedEvent(ctx,
&session.NewAggregate("sessionID", "instance1").Aggregate,
&domain.UserAgent{
FingerprintID: gu.Ptr("fp1"),
IP: net.ParseIP("1.2.3.4"),
Description: gu.Ptr("firefox"),
Header: http.Header{"foo": []string{"bar"}},
},
),
),
eventFromEventPusher(
session.NewUserCheckedEvent(ctx, &session.NewAggregate("sessionID", "instance1").Aggregate,
"userID", "orgID", testNow, &language.Afrikaans),
),
eventFromEventPusher(
session.NewPasswordCheckedEvent(ctx, &session.NewAggregate("sessionID", "instance1").Aggregate,
testNow),
),
eventFromEventPusherWithCreationDateNow(
session.NewLifetimeSetEvent(ctx, &session.NewAggregate("sessionID", "instance1").Aggregate,
2*time.Minute),
),
),
expectPushFailed(pushErr,
deviceauth.NewApprovedEvent(
ctx, deviceauth.NewAggregate("deviceCode", "instance1"), "userID", "orgID",
[]domain.UserAuthMethodType{domain.UserAuthMethodTypePassword},
testNow, &language.Afrikaans, &domain.UserAgent{
FingerprintID: gu.Ptr("fp1"),
IP: net.ParseIP("1.2.3.4"),
Description: gu.Ptr("firefox"),
Header: http.Header{"foo": []string{"bar"}},
},
"sessionID",
),
),
),
tokenVerifier: newMockTokenVerifierValid(),
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
ctx,
"deviceCode",
"sessionID",
"sessionToken",
},
wantErr: pushErr,
},
{
name: "authorized",
fields: fields{
eventstore: expectEventstore(
expectFilter(eventFromEventPusherWithInstanceID(
"instance1",
deviceauth.NewAddedEvent(
ctx,
deviceauth.NewAggregate("deviceCode", "instance1"),
"client_id", "deviceCode", "456", now,
[]string{"a", "b", "c"},
[]string{"projectID", "clientID"}, true,
),
)),
expectFilter(
eventFromEventPusher(
session.NewAddedEvent(ctx,
&session.NewAggregate("sessionID", "instance1").Aggregate,
&domain.UserAgent{
FingerprintID: gu.Ptr("fp1"),
IP: net.ParseIP("1.2.3.4"),
Description: gu.Ptr("firefox"),
Header: http.Header{"foo": []string{"bar"}},
},
),
),
eventFromEventPusher(
session.NewUserCheckedEvent(ctx, &session.NewAggregate("sessionID", "instance1").Aggregate,
"userID", "orgID", testNow, &language.Afrikaans),
),
eventFromEventPusher(
session.NewPasswordCheckedEvent(ctx, &session.NewAggregate("sessionID", "instance1").Aggregate,
testNow),
),
eventFromEventPusherWithCreationDateNow(
session.NewLifetimeSetEvent(ctx, &session.NewAggregate("sessionID", "instance1").Aggregate,
2*time.Minute),
),
),
expectPush(
deviceauth.NewApprovedEvent(
ctx, deviceauth.NewAggregate("deviceCode", "instance1"), "userID", "orgID",
[]domain.UserAuthMethodType{domain.UserAuthMethodTypePassword},
testNow, &language.Afrikaans, &domain.UserAgent{
FingerprintID: gu.Ptr("fp1"),
IP: net.ParseIP("1.2.3.4"),
Description: gu.Ptr("firefox"),
Header: http.Header{"foo": []string{"bar"}},
},
"sessionID",
),
),
),
tokenVerifier: newMockTokenVerifierValid(),
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
ctx,
"deviceCode",
"sessionID",
"sessionToken",
},
wantDetails: &domain.ObjectDetails{
ResourceOwner: "instance1",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore(t),
sessionTokenVerifier: tt.fields.tokenVerifier,
checkPermission: tt.fields.checkPermission,
}
gotDetails, err := c.ApproveDeviceAuthWithSession(tt.args.ctx, tt.args.deviceCode, tt.args.sessionID, tt.args.sessionToken)
require.ErrorIs(t, err, tt.wantErr)
assertObjectDetails(t, tt.wantDetails, gotDetails)
})
}
}
func TestCommands_CancelDeviceAuth(t *testing.T) {
ctx := authz.WithInstanceID(context.Background(), "instance1")
now := time.Now()

View File

@ -62,11 +62,13 @@ func (a *AuthRequestSAML) IsValid() bool {
}
type AuthRequestDevice struct {
ClientID string
DeviceCode string
UserCode string
Scopes []string
Audience []string
ClientID string
DeviceCode string
UserCode string
Scopes []string
Audience []string
AppName string
ProjectName string
}
func (*AuthRequestDevice) Type() AuthRequestType {

View File

@ -144,7 +144,17 @@ func (m *MockRepository) ExpectPushFailed(err error, expectedCommands []eventsto
assert.Equal(m.MockPusher.ctrl.T, expectedCommand.Creator(), commands[i].Creator())
assert.Equal(m.MockPusher.ctrl.T, expectedCommand.Type(), commands[i].Type())
assert.Equal(m.MockPusher.ctrl.T, expectedCommand.Revision(), commands[i].Revision())
assert.Equal(m.MockPusher.ctrl.T, expectedCommand.Payload(), commands[i].Payload())
var expectedPayload []byte
expectedPayload, ok := expectedCommand.Payload().([]byte)
if !ok {
expectedPayload, _ = json.Marshal(expectedCommand.Payload())
}
if string(expectedPayload) == "" {
expectedPayload = []byte("null")
}
gotPayload, _ := json.Marshal(commands[i].Payload())
assert.Equal(m.MockPusher.ctrl.T, expectedPayload, gotPayload)
assert.ElementsMatch(m.MockPusher.ctrl.T, expectedCommand.UniqueConstraints(), commands[i].UniqueConstraints())
}

View File

@ -466,3 +466,11 @@ func (i *Instance) CreateOIDCJWTProfileClient(ctx context.Context) (machine *man
return machine, name, keyResp.GetKeyDetails(), nil
}
func (i *Instance) CreateDeviceAuthorizationRequest(ctx context.Context, clientID string, scopes ...string) (*oidc.DeviceAuthorizationResponse, error) {
provider, err := i.CreateRelyingParty(ctx, clientID, "", scopes...)
if err != nil {
return nil, err
}
return rp.DeviceAuthorization(ctx, scopes, provider, nil)
}

View File

@ -86,15 +86,24 @@ var deviceAuthSelectColumns = []string{
DeviceAuthRequestColumnUserCode.identifier(),
DeviceAuthRequestColumnScopes.identifier(),
DeviceAuthRequestColumnAudience.identifier(),
AppColumnName.identifier(),
ProjectColumnName.identifier(),
}
func prepareDeviceAuthQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*domain.AuthRequestDevice, error)) {
return sq.Select(deviceAuthSelectColumns...).From(deviceAuthRequestTable.identifier()).PlaceholderFormat(sq.Dollar),
return sq.Select(deviceAuthSelectColumns...).
From(deviceAuthRequestTable.identifier()).
LeftJoin(join(AppOIDCConfigColumnClientID, DeviceAuthRequestColumnClientID)).
LeftJoin(join(AppColumnID, AppOIDCConfigColumnAppID)).
LeftJoin(join(ProjectColumnID, AppColumnProjectID)).
PlaceholderFormat(sq.Dollar),
func(row *sql.Row) (*domain.AuthRequestDevice, error) {
dst := new(domain.AuthRequestDevice)
var (
scopes database.TextArray[string]
audience database.TextArray[string]
scopes database.TextArray[string]
audience database.TextArray[string]
appName sql.NullString
projectName sql.NullString
)
err := row.Scan(
@ -103,15 +112,20 @@ func prepareDeviceAuthQuery(ctx context.Context, db prepareDatabase) (sq.SelectB
&dst.UserCode,
&scopes,
&audience,
&appName,
&projectName,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, zerrors.ThrowNotFound(err, "QUERY-Sah9a", "Errors.DeviceAuth.NotExisting")
return nil, zerrors.ThrowNotFound(err, "QUERY-Sah9a", "Errors.DeviceAuth.NotFound")
}
if err != nil {
return nil, zerrors.ThrowInternal(err, "QUERY-Voo3o", "Errors.Internal")
}
dst.Scopes = scopes
dst.Audience = audience
dst.AppName = appName.String
dst.ProjectName = projectName.String
return dst, nil
}
}

View File

@ -24,8 +24,17 @@ const (
` projections.device_auth_requests2.device_code,` +
` projections.device_auth_requests2.user_code,` +
` projections.device_auth_requests2.scopes,` +
` projections.device_auth_requests2.audience` +
` FROM projections.device_auth_requests2`
` projections.device_auth_requests2.audience,` +
` projections.apps7.name,` +
` projections.projects4.name` +
` FROM projections.device_auth_requests2` +
` LEFT JOIN projections.apps7_oidc_configs` +
` ON projections.device_auth_requests2.client_id = projections.apps7_oidc_configs.client_id` +
` AND projections.device_auth_requests2.instance_id = projections.apps7_oidc_configs.instance_id` +
` LEFT JOIN projections.apps7 ON projections.apps7_oidc_configs.app_id = projections.apps7.id` +
` AND projections.apps7_oidc_configs.instance_id = projections.apps7.instance_id` +
` LEFT JOIN projections.projects4 ON projections.apps7.project_id = projections.projects4.id` +
` AND projections.apps7.instance_id = projections.projects4.instance_id`
expectedDeviceAuthWhereUserCodeQueryC = expectedDeviceAuthQueryC +
` WHERE projections.device_auth_requests2.instance_id = $1` +
` AND projections.device_auth_requests2.user_code = $2`
@ -40,13 +49,17 @@ var (
"user-code",
database.TextArray[string]{"a", "b", "c"},
[]string{"projectID", "clientID"},
"appName",
"projectName",
}
expectedDeviceAuth = &domain.AuthRequestDevice{
ClientID: "client-id",
DeviceCode: "device1",
UserCode: "user-code",
Scopes: []string{"a", "b", "c"},
Audience: []string{"projectID", "clientID"},
ClientID: "client-id",
DeviceCode: "device1",
UserCode: "user-code",
Scopes: []string{"a", "b", "c"},
Audience: []string{"projectID", "clientID"},
AppName: "appName",
ProjectName: "projectName",
}
)

View File

@ -561,6 +561,7 @@ Errors:
AlreadyExists: Auth Request вече съществува
NotExisting: Auth Request не съществува
WrongLoginClient: Auth Request, създаден от друг клиент за влизане
AlreadyHandled: Заявката за удостоверяване вече е обработена
OIDCSession:
RefreshTokenInvalid: Токенът за опресняване е невалиден
Token:
@ -571,8 +572,12 @@ Errors:
AlreadyExists: SAMLRequest вече съществува
NotExisting: SAMLRequest не съществува
WrongLoginClient: SAMLRequest, създаден от друг клиент за влизане
AlreadyHandled: SAML заявката вече е обработена
SAMLSession:
InvalidClient: SAMLResponse не е издаден за този клиент
DeviceAuth:
NotFound: Заявката за авторизация на устройство не съществува
AlreadyHandled: Заявката за авторизация на устройство вече е обработена
Feature:
NotExisting: Функцията не съществува
TypeNotSupported: Типът функция не се поддържа

View File

@ -541,6 +541,7 @@ Errors:
AlreadyExists: Požadavek na autentizaci již existuje
NotExisting: Požadavek na autentizaci neexistuje
WrongLoginClient: Požadavek na autentizaci vytvořen jiným klientem přihlášení
AlreadyHandled: Žádost o ověření již byla zpracována
OIDCSession:
RefreshTokenInvalid: Obnovovací token je neplatný
Token:
@ -551,8 +552,12 @@ Errors:
AlreadyExists: SAMLRequest již existuje
NotExisting: SAMLRequest neexistuje
WrongLoginClient: SAMLRequest vytvořený jiným přihlašovacím klientem
AlreadyHandled: SAML požadavek již byl zpracován
SAMLSession:
InvalidClient: Pro tohoto klienta nebyla vydána odpověď SAMLResponse
DeviceAuth:
NotFound: Žádost o autorizaci zařízení neexistuje
AlreadyHandled: Žádost o autorizaci zařízení již byla zpracována
Feature:
NotExisting: Funkce neexistuje
TypeNotSupported: Typ funkce není podporován

View File

@ -543,6 +543,7 @@ Errors:
AlreadyExists: Auth Request existiert bereits
NotExisting: Auth Request existiert nicht
WrongLoginClient: Auth Request wurde von einem anderen Login-Client erstellt
AlreadyHandled: Auth Request wurde bereits bearbeitet
OIDCSession:
RefreshTokenInvalid: Refresh Token ist ungültig
Token:
@ -553,8 +554,12 @@ Errors:
AlreadyExists: SAMLRequest existiert bereits
NotExisting: SAMLRequest existiert nicht
WrongLoginClient: SAMLRequest wurde con einem andere Login-Client erstellt
AlreadyHandled: SAMLRequest wurde bereits bearbeitet
SAMLSession:
InvalidClient: SAMLResponse wurde nicht für diesen Client ausgestellt
DeviceAuth:
NotFound: Die Geräteautorisierungsanforderung existiert nicht
AlreadyHandled: Die Geräteautorisierungsanforderung wurde bereits bearbeitet
Feature:
NotExisting: Feature existiert nicht
TypeNotSupported: Feature Typ wird nicht unterstützt

View File

@ -544,6 +544,7 @@ Errors:
AlreadyExists: Auth Request already exists
NotExisting: Auth Request does not exist
WrongLoginClient: Auth Request created by other login client
AlreadyHandled: Auth Request has already been handled
OIDCSession:
RefreshTokenInvalid: Refresh Token is invalid
Token:
@ -554,8 +555,12 @@ Errors:
AlreadyExists: SAMLRequest already exists
NotExisting: SAMLRequest does not exist
WrongLoginClient: SAMLRequest created by other login client
AlreadyHandled: SAMLRequest has already been handled
SAMLSession:
InvalidClient: SAMLResponse was not issued for this client
DeviceAuth:
NotFound: Device Authorization Request does not exist
AlreadyHandled: Device Authorization Request has already been handled
Feature:
NotExisting: Feature does not exist
TypeNotSupported: Feature type is not supported

View File

@ -543,6 +543,7 @@ Errors:
AlreadyExists: Auth Request ya existe
NotExisting: Auth Request no existe
WrongLoginClient: Auth Request creado por otro cliente de inicio de sesión
AlreadyHandled: Auth Request ya ha sido procesada
OIDCSession:
RefreshTokenInvalid: El token de refresco no es válido
Token:
@ -553,8 +554,12 @@ Errors:
AlreadyExists: SAMLRequest ya existe
NotExisting: SAMLRequest no existe
WrongLoginClient: SAMLRequest creado por otro cliente de inicio de sesión
AlreadyHandled: SAMLRequest ya ha sido procesada
SAMLSession:
InvalidClient: SAMLResponse no ha sido emitido para este cliente
DeviceAuth:
NotFound: La solicitud de autorización del dispositivo no existe
AlreadyHandled: La solicitud de autorización del dispositivo ya ha sido procesada
Feature:
NotExisting: La característica no existe
TypeNotSupported: El tipo de característica no es compatible

View File

@ -543,6 +543,7 @@ Errors:
AlreadyExists: Auth Request existe déjà
NotExisting: Auth Request n'existe pas
WrongLoginClient: Auth Request créé par un autre client de connexion
AlreadyHandled: Auth Request a déjà été traitée
OIDCSession:
RefreshTokenInvalid: Le jeton de rafraîchissement n'est pas valide
Token:
@ -553,8 +554,12 @@ Errors:
AlreadyExists: SAMLRequest existe déjà
NotExisting: SAMLRequest n'existe pas
WrongLoginClient: SAMLRequest créé par un autre client de connexion
AlreadyHandled: SAMLRequest a déjà été traitée
SAMLSession:
InvalidClient: SAMLResponse n'a pas été émise pour ce client
DeviceAuth:
NotFound: La demande d'autorisation de l'appareil n'existe pas
AlreadyHandled: La demande d'autorisation de l'appareil a déjà été traitée
Feature:
NotExisting: La fonctionnalité n'existe pas
TypeNotSupported: Le type de fonctionnalité n'est pas pris en charge

View File

@ -543,6 +543,7 @@ Errors:
AlreadyExists: Az Auth Request már létezik
NotExisting: Az Auth Request nem létezik
WrongLoginClient: Az Auth Requestet egy másik bejelentkezési kliens hozta létre
AlreadyHandled: A hitelesítési kérelem már feldolgozva
OIDCSession:
RefreshTokenInvalid: Az Refresh Token érvénytelen
Token:
@ -553,8 +554,12 @@ Errors:
AlreadyExists: A SAMLRequest már létezik
NotExisting: A SAMLRequest nem létezik
WrongLoginClient: A SAMLRequest egy másik bejelentkezési ügyfél által létrehozott
AlreadyHandled: A SAMLRequest már feldolgozva
SAMLSession:
InvalidClient: SAMLResponse nem lett kiadva ehhez az ügyfélhez
DeviceAuth:
NotFound: Az eszközengedélyezési kérelem nem létezik
AlreadyHandled: Az eszközengedélyezési kérelem már feldolgozva
Feature:
NotExisting: A funkció nem létezik
TypeNotSupported: A funkció típusa nem támogatott

View File

@ -543,6 +543,7 @@ Errors:
AlreadyExists: Permintaan Otentikasi sudah ada
NotExisting: Permintaan Otentikasi tidak ada
WrongLoginClient: Permintaan Otentikasi dibuat oleh klien login lain
AlreadyHandled: Permintaan Otentikasi sudah ditangani
OIDCSession:
RefreshTokenInvalid: Token Penyegaran tidak valid
Token:
@ -553,8 +554,12 @@ Errors:
AlreadyExists: SAMLRequest sudah ada
NotExisting: SAMLRequest tidak ada
WrongLoginClient: SAMLRequest dibuat oleh klien login lainnya
AlreadyHandled: SAMLRequest sudah ditangani
SAMLSession:
InvalidClient: SAMLResponse tidak dikeluarkan untuk klien ini
DeviceAuth:
NotFound: Permintaan Otorisasi Perangkat tidak ada
AlreadyHandled: Permintaan Otorisasi Perangkat sudah ditangani
Feature:
NotExisting: Fitur tidak ada
TypeNotSupported: Jenis fitur tidak didukung

View File

@ -543,6 +543,7 @@ Errors:
AlreadyExists: Auth Request esiste già
NotExisting: Auth Request non esiste
WrongLoginClient: Auth Request creato da un altro client di accesso
AlreadyHandled: Auth Request è già stata gestita
OIDCSession:
RefreshTokenInvalid: Refresh Token non è valido
Token:
@ -553,8 +554,12 @@ Errors:
AlreadyExists: SAMLRequest esiste già
NotExisting: SAMLRequest non esiste
WrongLoginClient: SAMLRequest creato da un altro client di accesso
AlreadyHandled: SAMLRequest è già stata gestita
SAMLSession:
InvalidClient: SAMLResponse non è stato emesso per questo client
DeviceAuth:
NotFound: La richiesta di autorizzazione del dispositivo non esiste
AlreadyHandled: La richiesta di autorizzazione del dispositivo è già stata gestita
Feature:
NotExisting: La funzionalità non esiste
TypeNotSupported: Il tipo di funzionalità non è supportato

View File

@ -544,6 +544,7 @@ Errors:
AlreadyExists: AuthRequestはすでに存在する
NotExisting: AuthRequest が存在しません
WrongLoginClient: 他のログインクライアントによって作成された AuthRequest
AlreadyHandled: 認証リクエストは既に処理済みです
OIDCSession:
RefreshTokenInvalid: 無効なリフレッシュトークンです
Token:
@ -554,8 +555,12 @@ Errors:
AlreadyExists: SAMLリクエストはすでに存在します
NotExisting: SAMLリクエストが存在しません
WrongLoginClient: 他のログイン クライアントによって作成された SAMLRequest
AlreadyHandled: SAMLリクエストは既に処理済みです
SAMLSession:
InvalidClient: このクライアントに対してSAMLResponseは発行されませんでした
DeviceAuth:
NotFound: デバイス認証リクエストが存在しません
AlreadyHandled: デバイス認証リクエストは既に処理済みです
Feature:
NotExisting: 機能が存在しません
TypeNotSupported: 機能タイプはサポートされていません

View File

@ -544,6 +544,7 @@ Errors:
AlreadyExists: 인증 요청이 이미 존재합니다
NotExisting: 인증 요청이 존재하지 않습니다
WrongLoginClient: 다른 로그인 클라이언트에 의해 생성된 인증 요청
AlreadyHandled: 인증 요청이 이미 처리되었습니다
OIDCSession:
RefreshTokenInvalid: 새로 고침 토큰이 유효하지 않습니다
Token:
@ -554,8 +555,12 @@ Errors:
AlreadyExists: SAMLRequest가 이미 존재합니다
NotExisting: SAMLRequest가 존재하지 않습니다
WrongLoginClient: 다른 로그인 클라이언트가 생성한 SAMLRequest
AlreadyHandled: SAML 요청이 이미 처리되었습니다
SAMLSession:
InvalidClient: 이 클라이언트에 대해 SAMLResponse가 발행되지 않았습니다.
DeviceAuth:
NotFound: 장치 인증 요청이 존재하지 않습니다
AlreadyHandled: 장치 인증 요청이 이미 처리되었습니다
Feature:
NotExisting: 기능이 존재하지 않습니다
TypeNotSupported: 기능 유형이 지원되지 않습니다

View File

@ -542,6 +542,7 @@ Errors:
AlreadyExists: Барањето за автентикација веќе постои
NotExisting: Барањето за автентикација не постои
WrongLoginClient: Барањето за автификација беше креирано од друг клиент за најавување
AlreadyHandled: Барањето за автентикација е веќе обработено
OIDCSession:
RefreshTokenInvalid: Токенот за освежување е неважечки
Token:
@ -552,8 +553,12 @@ Errors:
AlreadyExists: SAMLRequest веќе постои
NotExisting: SAMLRequest не постои
WrongLoginClient: SAML Барање создадено од друг клиент за најавување
AlreadyHandled: SAML барањето е веќе обработено
SAMLSession:
InvalidClient: SAMLResponse не беше издаден за овој клиент
DeviceAuth:
NotFound: Барањето за авторизација на уредот не постои
AlreadyHandled: Барањето за авторизација на уредот е веќе обработено
Feature:
NotExisting: Функцијата не постои
TypeNotSupported: Типот на функција не е поддржан

View File

@ -543,6 +543,7 @@ Errors:
AlreadyExists: Auth Verzoek bestaat al
NotExisting: Auth Verzoek bestaat niet
WrongLoginClient: Auth Verzoek aangemaakt door andere login client
AlreadyHandled: Authenticatieverzoek is al verwerkt
OIDCSession:
RefreshTokenInvalid: Refresh Token is ongeldig
Token:
@ -553,8 +554,12 @@ Errors:
AlreadyExists: SAMLRequest bestaat al
NotExisting: SAMLRequest bestaat niet
WrongLoginClient: SAMLRequest aangemaakt door andere login client
AlreadyHandled: SAML-verzoek is al verwerkt
SAMLSession:
InvalidClient: SAMLResponse is niet uitgegeven voor deze client
DeviceAuth:
NotFound: Apparaatautorisatieverzoek bestaat niet
AlreadyHandled: Apparaatautorisatieverzoek is al verwerkt
Feature:
NotExisting: Functie bestaat niet
TypeNotSupported: Functie type wordt niet ondersteund

View File

@ -543,6 +543,7 @@ Errors:
AlreadyExists: Auth Request już istnieje
NotExisting: Auth Request nie istnieje
WrongLoginClient: Auth Request utworzony przez innego klienta logowania
AlreadyHandled: Żądanie uwierzytelnienia zostało już obsłużone
OIDCSession:
RefreshTokenInvalid: Refresh Token jest nieprawidłowy
Token:
@ -553,8 +554,12 @@ Errors:
AlreadyExists: SAMLRequest już istnieje
NotExisting: SAMLRequest nie istnieje
WrongLoginClient: SAMLRequest utworzony przez innego klienta logowania
AlreadyHandled: Żądanie SAML zostało już obsłużone
SAMLSession:
InvalidClient: SAMLResponse nie został wydany dla tego klienta
DeviceAuth:
NotFound: Żądanie autoryzacji urządzenia nie istnieje
AlreadyHandled: Żądanie autoryzacji urządzenia zostało już obsłużone
Feature:
NotExisting: Funkcja nie istnieje
TypeNotSupported: Typ funkcji nie jest obsługiwany

View File

@ -542,6 +542,7 @@ Errors:
AlreadyExists: A solicitação de autenticação já existe
NotExisting: A solicitação de autenticação não existe
WrongLoginClient: A solicitação de autenticação foi criada por outro cliente de login
AlreadyHandled: O pedido de autenticação já foi processado
OIDCSession:
RefreshTokenInvalid: O Refresh Token é inválido
Token:
@ -552,8 +553,12 @@ Errors:
AlreadyExists: O SAMLRequest já existe
NotExisting: O SAMLRequest não existe
WrongLoginClient: SAMLRequest criado por outro cliente de login
AlreadyHandled: O pedido SAML já foi processado
SAMLSession:
InvalidClient: O SAMLResponse não foi emitido para este cliente
DeviceAuth:
NotFound: O pedido de autorização do dispositivo não existe
AlreadyHandled: O pedido de autorização do dispositivo já foi processado
Feature:
NotExisting: O recurso não existe
TypeNotSupported: O tipo de recurso não é compatível

View File

@ -532,6 +532,7 @@ Errors:
AlreadyExists: Запрос на аутентификацию уже существует
NotExisting: Запрос на аутентификацию не существует
WrongLoginClient: Запрос на аутентификацию, созданный другим клиентом входа
AlreadyHandled: Запрос аутентификации уже обработан
OIDCSession:
RefreshTokenInvalid: Маркер обновления недействителен
Token:
@ -542,8 +543,12 @@ Errors:
AlreadyExists: SAMLRequest уже существует
NotExisting: SAMLRequest не существует
WrongLoginClient: SAMLRequest создан другим клиентом входа
AlreadyHandled: Запрос SAML уже обработан
SAMLSession:
InvalidClient: SAMLResponse не был отправлен для этого клиента
DeviceAuth:
NotFound: Запрос авторизации устройства не существует
AlreadyHandled: Запрос авторизации устройства уже обработан
Feature:
NotExisting: ункция не существует
TypeNotSupported: Тип объекта не поддерживается

View File

@ -543,6 +543,7 @@ Errors:
AlreadyExists: Autentiseringsbegäran finns redan
NotExisting: Autentiseringsbegäran existerar inte
WrongLoginClient: Autentiseringsbegäran skapad av annan inloggningsklient
AlreadyHandled: Autentiseringsbegäran har redan hanterats
OIDCSession:
RefreshTokenInvalid: Uppdateringstoken är ogiltig
Token:
@ -553,8 +554,12 @@ Errors:
AlreadyExists: SAMLRequest finns redan
NotExisting: SAMLRequest finns inte
WrongLoginClient: SAMLRequest skapad av annan inloggningsklient
AlreadyHandled: SAML-begäran har redan hanterats
SAMLSession:
InvalidClient: SAMLResponse utfärdades inte för den här klienten
DeviceAuth:
NotFound: Begäran om enhetsauktorisering finns inte
AlreadyHandled: Begäran om enhetsauktorisering har redan hanterats
Feature:
NotExisting: Funktionen existerar inte
TypeNotSupported: Funktionstypen stöds inte

View File

@ -543,6 +543,7 @@ Errors:
AlreadyExists: AuthRequest已经存在
NotExisting: AuthRequest不存在
WrongLoginClient: 其他登录客户端创建的AuthRequest
AlreadyHandled: 身份验证请求已被处理
OIDCSession:
RefreshTokenInvalid: Refresh Token 无效
Token:
@ -553,8 +554,12 @@ Errors:
AlreadyExists: SAMLRequest 已存在
NotExisting: SAMLRequest不存在
WrongLoginClient: 其他登录客户端创建的 SAMLRequest
AlreadyHandled: SAML请求已被处理
SAMLSession:
InvalidClient: 未向该客户端发出 SAMLResponse
DeviceAuth:
NotFound: 设备授权请求不存在
AlreadyHandled: 设备授权请求已被处理
Feature:
NotExisting: 功能不存在
TypeNotSupported: 不支持功能类型

View File

@ -115,3 +115,16 @@ enum ErrorReason {
ERROR_REASON_REQUEST_URI_NOT_SUPPORTED = 15;
ERROR_REASON_REGISTRATION_NOT_SUPPORTED = 16;
}
message DeviceAuthorizationRequest {
// The unique identifier of the device authorization request to be used for authorizing or denying the request.
string id = 1;
// The client_id of the application that initiated the device authorization request.
string client_id = 2;
// The scopes requested by the application.
repeated string scope = 3;
// Name of the client application.
string app_name = 4;
// Name of the project the client application is part of.
string project_name = 5;
}

View File

@ -147,6 +147,58 @@ service OIDCService {
};
};
}
// Get device authorization request
//
// Get the device authorization based on the provided "user code".
// This will return the device authorization request, which contains the device authorization id
// that is required to authorize the request once the user signed in or to deny it.
rpc GetDeviceAuthorizationRequest(GetDeviceAuthorizationRequestRequest) returns (GetDeviceAuthorizationRequestResponse) {
option (google.api.http) = {
get: "/v2/oidc/device_authorization/{user_code}"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "authenticated"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
responses: {
key: "200"
value: {
description: "OK";
}
};
};
}
// Authorize or deny device authorization
//
// Authorize or deny the device authorization request based on the provided device authorization id.
rpc AuthorizeOrDenyDeviceAuthorization(AuthorizeOrDenyDeviceAuthorizationRequest) returns (AuthorizeOrDenyDeviceAuthorizationResponse) {
option (google.api.http) = {
post: "/v2/oidc/device_authorization/{device_authorization_id}"
body: "*"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "authenticated"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
responses: {
key: "200"
value: {
description: "OK";
}
};
};
}
}
message GetAuthRequestRequest {
@ -217,3 +269,42 @@ message CreateCallbackResponse {
];
}
message GetDeviceAuthorizationRequestRequest {
// The user_code returned by the device authorization request and provided to the user by the device.
string user_code = 1 [
(validate.rules).string = {len: 9},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 9;
max_length: 9;
example: "\"K9LV-3DMQ\"";
}
];
}
message GetDeviceAuthorizationRequestResponse {
DeviceAuthorizationRequest device_authorization_request = 1;
}
message AuthorizeOrDenyDeviceAuthorizationRequest {
// The device authorization id returned when submitting the user code.
string device_authorization_id = 1 [
(validate.rules).string = {min_len: 1, max_len: 200},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1;
max_length: 200;
}
];
// The decision of the user to authorize or deny the device authorization request.
oneof decision {
option (validate.required) = true;
// To authorize the device authorization request, the user's session must be provided.
Session session = 2;
// Deny the device authorization request.
Deny deny = 3;
}
}
message Deny{}
message AuthorizeOrDenyDeviceAuthorizationResponse {}