From 911200aa9b89101ed2b1f23abfb70e1ef1e3b383 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Tue, 25 Feb 2025 07:33:13 +0100 Subject: [PATCH] feat(api): allow Device Authorization Grant using custom login UI (#9387) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 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 --- cmd/start/start.go | 2 +- .../guides/integrate/login-ui/device-auth.mdx | 165 ++++++++++ .../integrate/login-ui/oidc-standard.mdx | 6 +- .../integrate/login-ui/saml-standard.mdx | 4 +- docs/sidebars.js | 1 + .../img/guides/login-ui/device-auth-flow.png | Bin 0 -> 81814 bytes .../oidc/v2/integration_test/oidc_test.go | 225 +++++++++++++ internal/api/grpc/oidc/v2/oidc.go | 68 +++- internal/api/grpc/oidc/v2/server.go | 4 + .../integration_test/token_device_test.go | 127 ++++++++ internal/api/oidc/token_device.go | 5 +- internal/command/device_auth.go | 57 ++++ internal/command/device_auth_model.go | 1 + internal/command/device_auth_test.go | 305 ++++++++++++++++++ internal/domain/request.go | 12 +- .../repository/mock/repository.mock.impl.go | 12 +- internal/integration/oidc.go | 8 + internal/query/device_auth.go | 22 +- internal/query/device_auth_test.go | 27 +- internal/static/i18n/bg.yaml | 5 + internal/static/i18n/cs.yaml | 5 + internal/static/i18n/de.yaml | 5 + internal/static/i18n/en.yaml | 5 + internal/static/i18n/es.yaml | 5 + internal/static/i18n/fr.yaml | 5 + internal/static/i18n/hu.yaml | 5 + internal/static/i18n/id.yaml | 5 + internal/static/i18n/it.yaml | 5 + internal/static/i18n/ja.yaml | 5 + internal/static/i18n/ko.yaml | 5 + internal/static/i18n/mk.yaml | 5 + internal/static/i18n/nl.yaml | 5 + internal/static/i18n/pl.yaml | 5 + internal/static/i18n/pt.yaml | 5 + internal/static/i18n/ru.yaml | 5 + internal/static/i18n/sv.yaml | 5 + internal/static/i18n/zh.yaml | 5 + proto/zitadel/oidc/v2/authorization.proto | 13 + proto/zitadel/oidc/v2/oidc_service.proto | 91 ++++++ 39 files changed, 1210 insertions(+), 35 deletions(-) create mode 100644 docs/docs/guides/integrate/login-ui/device-auth.mdx create mode 100644 docs/static/img/guides/login-ui/device-auth-flow.png create mode 100644 internal/api/oidc/integration_test/token_device_test.go diff --git a/cmd/start/start.go b/cmd/start/start.go index 470aa24e3d..7e574d88a8 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -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 diff --git a/docs/docs/guides/integrate/login-ui/device-auth.mdx b/docs/docs/guides/integrate/login-ui/device-auth.mdx new file mode 100644 index 0000000000..f60fad1310 --- /dev/null +++ b/docs/docs/guides/integrate/login-ui/device-auth.mdx @@ -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). \ No newline at end of file diff --git a/docs/docs/guides/integrate/login-ui/oidc-standard.mdx b/docs/docs/guides/integrate/login-ui/oidc-standard.mdx index f05d0d99b1..c96338fbf0 100644 --- a/docs/docs/guides/integrate/login-ui/oidc-standard.mdx +++ b/docs/docs/guides/integrate/login-ui/oidc-standard.mdx @@ -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 diff --git a/docs/docs/guides/integrate/login-ui/saml-standard.mdx b/docs/docs/guides/integrate/login-ui/saml-standard.mdx index a2cb907874..8114350d5d 100644 --- a/docs/docs/guides/integrate/login-ui/saml-standard.mdx +++ b/docs/docs/guides/integrate/login-ui/saml-standard.mdx @@ -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: diff --git a/docs/sidebars.js b/docs/sidebars.js index 52eef6fffe..6bcd0cf05c 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -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", ], }, diff --git a/docs/static/img/guides/login-ui/device-auth-flow.png b/docs/static/img/guides/login-ui/device-auth-flow.png new file mode 100644 index 0000000000000000000000000000000000000000..f240df32f430b380fa61aae8fffbc489214da6e2 GIT binary patch literal 81814 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⁣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 C!{zb- literal 0 HcmV?d00001 diff --git a/internal/api/grpc/oidc/v2/integration_test/oidc_test.go b/internal/api/grpc/oidc/v2/integration_test/oidc_test.go index d6b5c7b8cf..1eb031bd6d 100644 --- a/internal/api/grpc/oidc/v2/integration_test/oidc_test.go +++ b/internal/api/grpc/oidc/v2/integration_test/oidc_test.go @@ -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{ diff --git a/internal/api/grpc/oidc/v2/oidc.go b/internal/api/grpc/oidc/v2/oidc.go index d1ddc35cc0..73fc995be2 100644 --- a/internal/api/grpc/oidc/v2/oidc.go +++ b/internal/api/grpc/oidc/v2/oidc.go @@ -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()) +} diff --git a/internal/api/grpc/oidc/v2/server.go b/internal/api/grpc/oidc/v2/server.go index 28c7134904..99234ee3d7 100644 --- a/internal/api/grpc/oidc/v2/server.go +++ b/internal/api/grpc/oidc/v2/server.go @@ -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, } } diff --git a/internal/api/oidc/integration_test/token_device_test.go b/internal/api/oidc/integration_test/token_device_test.go new file mode 100644 index 0000000000..0c6a65e8a2 --- /dev/null +++ b/internal/api/oidc/integration_test/token_device_test.go @@ -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 + }) + } +} diff --git a/internal/api/oidc/token_device.go b/internal/api/oidc/token_device.go index 464e9e46ae..8e0f8dc993 100644 --- a/internal/api/oidc/token_device.go +++ b/internal/api/oidc/token_device.go @@ -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) } diff --git a/internal/command/device_auth.go b/internal/command/device_auth.go index a2754650ea..d3588660be 100644 --- a/internal/command/device_auth.go +++ b/internal/command/device_auth.go @@ -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 { diff --git a/internal/command/device_auth_model.go b/internal/command/device_auth_model.go index 21ab1b29ec..28833ae898 100644 --- a/internal/command/device_auth_model.go +++ b/internal/command/device_auth_model.go @@ -82,6 +82,7 @@ func (m *DeviceAuthWriteModel) Query() *eventstore.SearchQueryBuilder { deviceauth.AddedEventType, deviceauth.ApprovedEventType, deviceauth.CanceledEventType, + deviceauth.DoneEventType, ). Builder() } diff --git a/internal/command/device_auth_test.go b/internal/command/device_auth_test.go index f25be7053a..508ca10571 100644 --- a/internal/command/device_auth_test.go +++ b/internal/command/device_auth_test.go @@ -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() diff --git a/internal/domain/request.go b/internal/domain/request.go index 1b54cfa41c..92e45c0d2f 100644 --- a/internal/domain/request.go +++ b/internal/domain/request.go @@ -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 { diff --git a/internal/eventstore/repository/mock/repository.mock.impl.go b/internal/eventstore/repository/mock/repository.mock.impl.go index 9ae0b6b1ea..ced76953cb 100644 --- a/internal/eventstore/repository/mock/repository.mock.impl.go +++ b/internal/eventstore/repository/mock/repository.mock.impl.go @@ -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()) } diff --git a/internal/integration/oidc.go b/internal/integration/oidc.go index 4d7f5277c9..19742741ab 100644 --- a/internal/integration/oidc.go +++ b/internal/integration/oidc.go @@ -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) +} diff --git a/internal/query/device_auth.go b/internal/query/device_auth.go index d63bfe0209..e42b5a114e 100644 --- a/internal/query/device_auth.go +++ b/internal/query/device_auth.go @@ -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 } } diff --git a/internal/query/device_auth_test.go b/internal/query/device_auth_test.go index f81a11b411..6f0f82b3be 100644 --- a/internal/query/device_auth_test.go +++ b/internal/query/device_auth_test.go @@ -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", } ) diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index 5539fabb12..d7dc18898b 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -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: Типът функция не се поддържа diff --git a/internal/static/i18n/cs.yaml b/internal/static/i18n/cs.yaml index 6a21286a2c..80db4952f9 100644 --- a/internal/static/i18n/cs.yaml +++ b/internal/static/i18n/cs.yaml @@ -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 diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index 117bbcb897..dcb3ac5c71 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -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 diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index 618f4a500a..bd8d26d727 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -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 diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index b2c0c0a685..9f11b63964 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -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 diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index eb143e592c..ff8393befc 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -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 diff --git a/internal/static/i18n/hu.yaml b/internal/static/i18n/hu.yaml index d33b5f47bc..b17c6a1225 100644 --- a/internal/static/i18n/hu.yaml +++ b/internal/static/i18n/hu.yaml @@ -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 diff --git a/internal/static/i18n/id.yaml b/internal/static/i18n/id.yaml index 449f91ffdc..56a454e71d 100644 --- a/internal/static/i18n/id.yaml +++ b/internal/static/i18n/id.yaml @@ -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 diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index a94925a906..6713abf2e1 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -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 diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index 6cdebc12a6..f57d0f6661 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -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: 機能タイプはサポートされていません diff --git a/internal/static/i18n/ko.yaml b/internal/static/i18n/ko.yaml index 741f075ca2..d238142e01 100644 --- a/internal/static/i18n/ko.yaml +++ b/internal/static/i18n/ko.yaml @@ -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: 기능 유형이 지원되지 않습니다 diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index be205f5380..898ed67360 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -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: Типот на функција не е поддржан diff --git a/internal/static/i18n/nl.yaml b/internal/static/i18n/nl.yaml index 262b24f2fb..882c58a4f2 100644 --- a/internal/static/i18n/nl.yaml +++ b/internal/static/i18n/nl.yaml @@ -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 diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index cc511ec0c0..13125bc2a9 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -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 diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index bd106ab259..4ab3573c2b 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -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 diff --git a/internal/static/i18n/ru.yaml b/internal/static/i18n/ru.yaml index 8c4b079f2e..64a8ef8013 100644 --- a/internal/static/i18n/ru.yaml +++ b/internal/static/i18n/ru.yaml @@ -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: Тип объекта не поддерживается diff --git a/internal/static/i18n/sv.yaml b/internal/static/i18n/sv.yaml index e31095b78c..2c292976d3 100644 --- a/internal/static/i18n/sv.yaml +++ b/internal/static/i18n/sv.yaml @@ -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 diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index b30609da90..d4b36df7ff 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -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: 不支持功能类型 diff --git a/proto/zitadel/oidc/v2/authorization.proto b/proto/zitadel/oidc/v2/authorization.proto index c0ad751624..6cdf55de64 100644 --- a/proto/zitadel/oidc/v2/authorization.proto +++ b/proto/zitadel/oidc/v2/authorization.proto @@ -114,4 +114,17 @@ enum ErrorReason { ERROR_REASON_REQUEST_NOT_SUPPORTED = 14; 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; } \ No newline at end of file diff --git a/proto/zitadel/oidc/v2/oidc_service.proto b/proto/zitadel/oidc/v2/oidc_service.proto index 3c36057afa..e305cbfe9a 100644 --- a/proto/zitadel/oidc/v2/oidc_service.proto +++ b/proto/zitadel/oidc/v2/oidc_service.proto @@ -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 {} \ No newline at end of file