From a80e7d7c5fd8171994dd891b005d408c99bcadd6 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Tue, 18 Apr 2023 14:55:21 +0200 Subject: [PATCH 01/15] feat: support app-based OIDC flows --- .github/workflows/ci.yaml | 14 +- .gitignore | 1 + cmd/cleanup/sql_test.go | 4 +- driver/registry.go | 4 + driver/registry_default.go | 5 + .../registry_default_sessiontokenexchange.go | 18 ++ internal/client-go/.openapi-generator/FILES | 2 + internal/client-go/README.md | 2 + internal/client-go/api_frontend.go | 207 +++++++++++++++++- internal/client-go/model_login_flow.go | 37 ++++ internal/client-go/model_registration_flow.go | 37 ++++ ...model_successful_code_exchange_response.go | 144 ++++++++++++ internal/httpclient/.openapi-generator/FILES | 2 + internal/httpclient/README.md | 2 + internal/httpclient/api_frontend.go | 207 +++++++++++++++++- internal/httpclient/model_login_flow.go | 37 ++++ .../httpclient/model_registration_flow.go | 37 ++++ ...model_successful_code_exchange_response.go | 144 ++++++++++++ persistence/reference.go | 2 + ...01_create_session_token_exchanges.down.sql | 1 + ...ate_session_token_exchanges.mysql.down.sql | 1 + ...reate_session_token_exchanges.mysql.up.sql | 15 ++ ...0001_create_session_token_exchanges.up.sql | 15 ++ .../sql/persister_sessiontokenexchanger.go | 86 ++++++++ persistence/sql/persister_test.go | 5 + selfservice/flow/flow.go | 6 + selfservice/flow/login/error.go | 7 + selfservice/flow/login/flow.go | 15 ++ selfservice/flow/login/handler.go | 31 ++- selfservice/flow/login/handler_test.go | 13 ++ selfservice/flow/login/hook.go | 10 + selfservice/flow/login/hook_test.go | 2 +- selfservice/flow/registration/error.go | 6 + selfservice/flow/registration/flow.go | 15 ++ selfservice/flow/registration/handler.go | 34 +++ selfservice/flow/registration/handler_test.go | 30 ++- selfservice/flow/registration/hook.go | 10 + selfservice/hook/session_issuer.go | 17 ++ selfservice/sessiontokenexchange/handler.go | 126 +++++++++++ .../sessiontokenexchange/persistence.go | 43 ++++ .../sessiontokenexchange/test/persistence.go | 143 ++++++++++++ selfservice/strategy/oidc/error.go | 3 - selfservice/strategy/oidc/strategy.go | 93 ++++---- selfservice/strategy/oidc/strategy_login.go | 11 +- .../strategy/oidc/strategy_registration.go | 11 +- spec/api.json | 130 +++++++++++ spec/swagger.json | 107 +++++++++ test/e2e/.gitignore | 6 + test/e2e/package-lock.json | 86 ++++++++ test/e2e/package.json | 8 +- test/e2e/playwright/setup/default_config.ts | 125 +++++++++++ test/e2e/playwright/setup/global_setup.ts | 12 + test/e2e/playwright/tests/app_login.spec.ts | 118 ++++++++++ test/e2e/run.sh | 8 + x/redir_test.go | 4 +- x/xsql/sql.go | 2 + 56 files changed, 2175 insertions(+), 86 deletions(-) create mode 100644 driver/registry_default_sessiontokenexchange.go create mode 100644 internal/client-go/model_successful_code_exchange_response.go create mode 100644 internal/httpclient/model_successful_code_exchange_response.go create mode 100644 persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.down.sql create mode 100644 persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.mysql.down.sql create mode 100644 persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.mysql.up.sql create mode 100644 persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.up.sql create mode 100644 persistence/sql/persister_sessiontokenexchanger.go create mode 100644 selfservice/sessiontokenexchange/handler.go create mode 100644 selfservice/sessiontokenexchange/persistence.go create mode 100644 selfservice/sessiontokenexchange/test/persistence.go create mode 100644 test/e2e/.gitignore create mode 100644 test/e2e/playwright/setup/default_config.ts create mode 100644 test/e2e/playwright/setup/global_setup.ts create mode 100644 test/e2e/playwright/tests/app_login.spec.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6f7bbbf5adde..de5757977da3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -203,13 +203,23 @@ jobs: echo 'RN_UI_PATH='"$(realpath react-native-ui)" >> $GITHUB_ENV echo 'NODE_UI_PATH='"$(realpath node-ui)" >> $GITHUB_ENV echo 'REACT_UI_PATH='"$(realpath react-ui)" >> $GITHUB_ENV - - run: | - ./test/e2e/run.sh ${{ matrix.database }} + - name: "Run Cypress tests" + run: ./test/e2e/run.sh ${{ matrix.database }} env: RN_UI_PATH: react-native-ui NODE_UI_PATH: node-ui REACT_UI_PATH: react-ui CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} + # TODO(hperl): Enable this once the React Native app uses the new SDK + # - name: "Run Playwright tests" + # run: | + # cd test/e2e + # npm run playwright + # env: + # DB: ${{ matrix.database }} + # RN_UI_PATH: react-native-ui + # NODE_UI_PATH: node-ui + # REACT_UI_PATH: react-ui - if: failure() uses: actions/upload-artifact@v2 with: diff --git a/.gitignore b/.gitignore index fdf1a69a8613..c11322756c5c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ test/e2e/.bin pkged.go coverage.* schema.sql +*.sqlite heap_profiler/ goroutine_dump/ inflight_trace_dump/ diff --git a/cmd/cleanup/sql_test.go b/cmd/cleanup/sql_test.go index ab339b240900..d0f925a5beba 100644 --- a/cmd/cleanup/sql_test.go +++ b/cmd/cleanup/sql_test.go @@ -15,7 +15,7 @@ func Test_ExecuteCleanupFailedDSN(t *testing.T) { b := bytes.NewBufferString("") cmd.SetOut(b) cmd.SetArgs([]string{"--read-from-env=false"}) - cmd.Execute() + _ = cmd.Execute() out, err := io.ReadAll(b) if err != nil { t.Fatal(err) @@ -23,5 +23,5 @@ func Test_ExecuteCleanupFailedDSN(t *testing.T) { if !strings.Contains(string(out), "expected to get the DSN as an argument") { t.Fatalf("expected \"%s\" got \"%s\"", "expected to get the DSN as an argument", string(out)) } - cmd.Execute() + _ = cmd.Execute() } diff --git a/driver/registry.go b/driver/registry.go index 4883d14ab9ce..a3a250109df3 100644 --- a/driver/registry.go +++ b/driver/registry.go @@ -6,6 +6,7 @@ package driver import ( "context" + "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/x/contextx" "github.com/ory/x/jsonnetsecure" "github.com/ory/x/otelx" @@ -138,6 +139,9 @@ type Registry interface { verification.HandlerProvider verification.StrategyProvider + sessiontokenexchange.HandlerProvider + sessiontokenexchange.PersistenceProvider + link.SenderProvider link.VerificationTokenPersistenceProvider link.RecoveryTokenPersistenceProvider diff --git a/driver/registry_default.go b/driver/registry_default.go index f4e9ba3fb040..ea655ecb1ec4 100644 --- a/driver/registry_default.go +++ b/driver/registry_default.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/x/contextx" "github.com/ory/x/jsonnetsecure" @@ -132,6 +133,8 @@ type RegistryDefault struct { selfserviceLoginHandler *login.Handler selfserviceLoginRequestErrorHandler *login.ErrorHandler + sessionTokenExchangeHandler *sessiontokenexchange.Handler + selfserviceSettingsHandler *settings.Handler selfserviceSettingsErrorHandler *settings.ErrorHandler selfserviceSettingsExecutor *settings.HookExecutor @@ -187,6 +190,7 @@ func (m *RegistryDefault) RegisterPublicRoutes(ctx context.Context, router *x.Ro m.SessionHandler().RegisterPublicRoutes(router) m.SelfServiceErrorHandler().RegisterPublicRoutes(router) m.SchemaHandler().RegisterPublicRoutes(router) + m.SessionTokenExchangeHandler().RegisterPublicRoutes(router) m.AllRecoveryStrategies().RegisterPublicRoutes(router) m.RecoveryHandler().RegisterPublicRoutes(router) @@ -206,6 +210,7 @@ func (m *RegistryDefault) RegisterAdminRoutes(ctx context.Context, router *x.Rou m.IdentityHandler().RegisterAdminRoutes(router) m.CourierHandler().RegisterAdminRoutes(router) m.SelfServiceErrorHandler().RegisterAdminRoutes(router) + m.SessionTokenExchangeHandler().RegisterAdminRoutes(router) m.RecoveryHandler().RegisterAdminRoutes(router) m.AllRecoveryStrategies().RegisterAdminRoutes(router) diff --git a/driver/registry_default_sessiontokenexchange.go b/driver/registry_default_sessiontokenexchange.go new file mode 100644 index 000000000000..026f900e52d3 --- /dev/null +++ b/driver/registry_default_sessiontokenexchange.go @@ -0,0 +1,18 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package driver + +import "github.com/ory/kratos/selfservice/sessiontokenexchange" + +func (m *RegistryDefault) SessionTokenExchangeHandler() *sessiontokenexchange.Handler { + if m.sessionTokenExchangeHandler == nil { + m.sessionTokenExchangeHandler = sessiontokenexchange.NewHandler(m) + } + + return m.sessionTokenExchangeHandler +} + +func (m *RegistryDefault) SessionTokenExchangePersister() sessiontokenexchange.Persister { + return m.Persister() +} diff --git a/internal/client-go/.openapi-generator/FILES b/internal/client-go/.openapi-generator/FILES index 251abdbd0d87..f7968b85c90e 100644 --- a/internal/client-go/.openapi-generator/FILES +++ b/internal/client-go/.openapi-generator/FILES @@ -75,6 +75,7 @@ docs/SessionAuthenticationMethod.md docs/SessionDevice.md docs/SettingsFlow.md docs/SettingsFlowState.md +docs/SuccessfulCodeExchangeResponse.md docs/SuccessfulNativeLogin.md docs/SuccessfulNativeRegistration.md docs/TokenPagination.md @@ -183,6 +184,7 @@ model_session_authentication_method.go model_session_device.go model_settings_flow.go model_settings_flow_state.go +model_successful_code_exchange_response.go model_successful_native_login.go model_successful_native_registration.go model_token_pagination.go diff --git a/internal/client-go/README.md b/internal/client-go/README.md index 7d9a781028ce..4549734d1e2c 100644 --- a/internal/client-go/README.md +++ b/internal/client-go/README.md @@ -94,6 +94,7 @@ Class | Method | HTTP request | Description *FrontendApi* | [**CreateNativeVerificationFlow**](docs/FrontendApi.md#createnativeverificationflow) | **Get** /self-service/verification/api | Create Verification Flow for Native Apps *FrontendApi* | [**DisableMyOtherSessions**](docs/FrontendApi.md#disablemyothersessions) | **Delete** /sessions | Disable my other sessions *FrontendApi* | [**DisableMySession**](docs/FrontendApi.md#disablemysession) | **Delete** /sessions/{id} | Disable one of my sessions +*FrontendApi* | [**ExchangeSessionToken**](docs/FrontendApi.md#exchangesessiontoken) | **Get** /self-service/exchange-code-for-session-token | Exchange Session Token *FrontendApi* | [**GetFlowError**](docs/FrontendApi.md#getflowerror) | **Get** /self-service/errors | Get User-Flow Errors *FrontendApi* | [**GetLoginFlow**](docs/FrontendApi.md#getloginflow) | **Get** /self-service/login/flows | Get Login Flow *FrontendApi* | [**GetRecoveryFlow**](docs/FrontendApi.md#getrecoveryflow) | **Get** /self-service/recovery/flows | Get Recovery Flow @@ -198,6 +199,7 @@ Class | Method | HTTP request | Description - [SessionDevice](docs/SessionDevice.md) - [SettingsFlow](docs/SettingsFlow.md) - [SettingsFlowState](docs/SettingsFlowState.md) + - [SuccessfulCodeExchangeResponse](docs/SuccessfulCodeExchangeResponse.md) - [SuccessfulNativeLogin](docs/SuccessfulNativeLogin.md) - [SuccessfulNativeRegistration](docs/SuccessfulNativeRegistration.md) - [TokenPagination](docs/TokenPagination.md) diff --git a/internal/client-go/api_frontend.go b/internal/client-go/api_frontend.go index d2db9f9b432e..e4fe9177eeea 100644 --- a/internal/client-go/api_frontend.go +++ b/internal/client-go/api_frontend.go @@ -387,6 +387,19 @@ type FrontendApi interface { */ DisableMySessionExecute(r FrontendApiApiDisableMySessionRequest) (*http.Response, error) + /* + * ExchangeSessionToken Exchange Session Token + * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + * @return FrontendApiApiExchangeSessionTokenRequest + */ + ExchangeSessionToken(ctx context.Context) FrontendApiApiExchangeSessionTokenRequest + + /* + * ExchangeSessionTokenExecute executes the request + * @return SuccessfulNativeLogin + */ + ExchangeSessionTokenExecute(r FrontendApiApiExchangeSessionTokenRequest) (*SuccessfulNativeLogin, *http.Response, error) + /* * GetFlowError Get User-Flow Errors * This endpoint returns the error associated with a user-facing self service errors. @@ -1844,11 +1857,13 @@ func (a *FrontendApiService) CreateBrowserVerificationFlowExecute(r FrontendApiA } type FrontendApiApiCreateNativeLoginFlowRequest struct { - ctx context.Context - ApiService FrontendApi - refresh *bool - aal *string - xSessionToken *string + ctx context.Context + ApiService FrontendApi + refresh *bool + aal *string + xSessionToken *string + enableSessionTokenExchangeCode *bool + returnTo *string } func (r FrontendApiApiCreateNativeLoginFlowRequest) Refresh(refresh bool) FrontendApiApiCreateNativeLoginFlowRequest { @@ -1863,6 +1878,14 @@ func (r FrontendApiApiCreateNativeLoginFlowRequest) XSessionToken(xSessionToken r.xSessionToken = &xSessionToken return r } +func (r FrontendApiApiCreateNativeLoginFlowRequest) EnableSessionTokenExchangeCode(enableSessionTokenExchangeCode bool) FrontendApiApiCreateNativeLoginFlowRequest { + r.enableSessionTokenExchangeCode = &enableSessionTokenExchangeCode + return r +} +func (r FrontendApiApiCreateNativeLoginFlowRequest) ReturnTo(returnTo string) FrontendApiApiCreateNativeLoginFlowRequest { + r.returnTo = &returnTo + return r +} func (r FrontendApiApiCreateNativeLoginFlowRequest) Execute() (*LoginFlow, *http.Response, error) { return r.ApiService.CreateNativeLoginFlowExecute(r) @@ -1931,6 +1954,12 @@ func (a *FrontendApiService) CreateNativeLoginFlowExecute(r FrontendApiApiCreate if r.aal != nil { localVarQueryParams.Add("aal", parameterToString(*r.aal, "")) } + if r.enableSessionTokenExchangeCode != nil { + localVarQueryParams.Add("enable_session_token_exchange_code", parameterToString(*r.enableSessionTokenExchangeCode, "")) + } + if r.returnTo != nil { + localVarQueryParams.Add("return_to", parameterToString(*r.returnTo, "")) + } // to determine the Content-Type header localVarHTTPContentTypes := []string{} @@ -2136,8 +2165,19 @@ func (a *FrontendApiService) CreateNativeRecoveryFlowExecute(r FrontendApiApiCre } type FrontendApiApiCreateNativeRegistrationFlowRequest struct { - ctx context.Context - ApiService FrontendApi + ctx context.Context + ApiService FrontendApi + enableSessionTokenExchangeCode *bool + returnTo *string +} + +func (r FrontendApiApiCreateNativeRegistrationFlowRequest) EnableSessionTokenExchangeCode(enableSessionTokenExchangeCode bool) FrontendApiApiCreateNativeRegistrationFlowRequest { + r.enableSessionTokenExchangeCode = &enableSessionTokenExchangeCode + return r +} +func (r FrontendApiApiCreateNativeRegistrationFlowRequest) ReturnTo(returnTo string) FrontendApiApiCreateNativeRegistrationFlowRequest { + r.returnTo = &returnTo + return r } func (r FrontendApiApiCreateNativeRegistrationFlowRequest) Execute() (*RegistrationFlow, *http.Response, error) { @@ -2200,6 +2240,12 @@ func (a *FrontendApiService) CreateNativeRegistrationFlowExecute(r FrontendApiAp localVarQueryParams := url.Values{} localVarFormParams := url.Values{} + if r.enableSessionTokenExchangeCode != nil { + localVarQueryParams.Add("enable_session_token_exchange_code", parameterToString(*r.enableSessionTokenExchangeCode, "")) + } + if r.returnTo != nil { + localVarQueryParams.Add("return_to", parameterToString(*r.returnTo, "")) + } // to determine the Content-Type header localVarHTTPContentTypes := []string{} @@ -2835,6 +2881,153 @@ func (a *FrontendApiService) DisableMySessionExecute(r FrontendApiApiDisableMySe return localVarHTTPResponse, nil } +type FrontendApiApiExchangeSessionTokenRequest struct { + ctx context.Context + ApiService FrontendApi + code *string +} + +func (r FrontendApiApiExchangeSessionTokenRequest) Code(code string) FrontendApiApiExchangeSessionTokenRequest { + r.code = &code + return r +} + +func (r FrontendApiApiExchangeSessionTokenRequest) Execute() (*SuccessfulNativeLogin, *http.Response, error) { + return r.ApiService.ExchangeSessionTokenExecute(r) +} + +/* + * ExchangeSessionToken Exchange Session Token + * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + * @return FrontendApiApiExchangeSessionTokenRequest + */ +func (a *FrontendApiService) ExchangeSessionToken(ctx context.Context) FrontendApiApiExchangeSessionTokenRequest { + return FrontendApiApiExchangeSessionTokenRequest{ + ApiService: a, + ctx: ctx, + } +} + +/* + * Execute executes the request + * @return SuccessfulNativeLogin + */ +func (a *FrontendApiService) ExchangeSessionTokenExecute(r FrontendApiApiExchangeSessionTokenRequest) (*SuccessfulNativeLogin, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + localVarFormFileName string + localVarFileName string + localVarFileBytes []byte + localVarReturnValue *SuccessfulNativeLogin + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "FrontendApiService.ExchangeSessionToken") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/self-service/exchange-code-for-session-token" + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + if r.code == nil { + return localVarReturnValue, nil, reportError("code is required and must be specified") + } + + localVarQueryParams.Add("code", parameterToString(*r.code, "")) + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, localVarFormFileName, localVarFileName, localVarFileBytes) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 403 { + var v ErrorGeneric + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 404 { + var v ErrorGeneric + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 410 { + var v ErrorGeneric + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + var v ErrorGeneric + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + type FrontendApiApiGetFlowErrorRequest struct { ctx context.Context ApiService FrontendApi diff --git a/internal/client-go/model_login_flow.go b/internal/client-go/model_login_flow.go index d96b29852b48..1fc6fe2056a8 100644 --- a/internal/client-go/model_login_flow.go +++ b/internal/client-go/model_login_flow.go @@ -36,6 +36,8 @@ type LoginFlow struct { RequestedAal *AuthenticatorAssuranceLevel `json:"requested_aal,omitempty"` // ReturnTo contains the requested return_to URL. ReturnTo *string `json:"return_to,omitempty"` + // SessionTokenExchangeCode holds the secret code that the client can use to retrieve a session token after the login flow has been completed. This is only set if the client has requested a session token exchange code, and if the flow is of type \"api\", and only on creating the login flow. + SessionTokenExchangeCode *string `json:"session_token_exchange_code,omitempty"` // The flow type can either be `api` or `browser`. Type string `json:"type"` Ui UiContainer `json:"ui"` @@ -397,6 +399,38 @@ func (o *LoginFlow) SetReturnTo(v string) { o.ReturnTo = &v } +// GetSessionTokenExchangeCode returns the SessionTokenExchangeCode field value if set, zero value otherwise. +func (o *LoginFlow) GetSessionTokenExchangeCode() string { + if o == nil || o.SessionTokenExchangeCode == nil { + var ret string + return ret + } + return *o.SessionTokenExchangeCode +} + +// GetSessionTokenExchangeCodeOk returns a tuple with the SessionTokenExchangeCode field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *LoginFlow) GetSessionTokenExchangeCodeOk() (*string, bool) { + if o == nil || o.SessionTokenExchangeCode == nil { + return nil, false + } + return o.SessionTokenExchangeCode, true +} + +// HasSessionTokenExchangeCode returns a boolean if a field has been set. +func (o *LoginFlow) HasSessionTokenExchangeCode() bool { + if o != nil && o.SessionTokenExchangeCode != nil { + return true + } + + return false +} + +// SetSessionTokenExchangeCode gets a reference to the given string and assigns it to the SessionTokenExchangeCode field. +func (o *LoginFlow) SetSessionTokenExchangeCode(v string) { + o.SessionTokenExchangeCode = &v +} + // GetType returns the Type field value func (o *LoginFlow) GetType() string { if o == nil { @@ -512,6 +546,9 @@ func (o LoginFlow) MarshalJSON() ([]byte, error) { if o.ReturnTo != nil { toSerialize["return_to"] = o.ReturnTo } + if o.SessionTokenExchangeCode != nil { + toSerialize["session_token_exchange_code"] = o.SessionTokenExchangeCode + } if true { toSerialize["type"] = o.Type } diff --git a/internal/client-go/model_registration_flow.go b/internal/client-go/model_registration_flow.go index 0cf620898a8c..a28af14f1cc4 100644 --- a/internal/client-go/model_registration_flow.go +++ b/internal/client-go/model_registration_flow.go @@ -31,6 +31,8 @@ type RegistrationFlow struct { RequestUrl string `json:"request_url"` // ReturnTo contains the requested return_to URL. ReturnTo *string `json:"return_to,omitempty"` + // SessionTokenExchangeCode holds the secret code that the client can use to retrieve a session token after the flow has been completed. This is only set if the client has requested a session token exchange code, and if the flow is of type \"api\", and only on creating the flow. + SessionTokenExchangeCode *string `json:"session_token_exchange_code,omitempty"` // TransientPayload is used to pass data from the registration to a webhook TransientPayload map[string]interface{} `json:"transient_payload,omitempty"` // The flow type can either be `api` or `browser`. @@ -296,6 +298,38 @@ func (o *RegistrationFlow) SetReturnTo(v string) { o.ReturnTo = &v } +// GetSessionTokenExchangeCode returns the SessionTokenExchangeCode field value if set, zero value otherwise. +func (o *RegistrationFlow) GetSessionTokenExchangeCode() string { + if o == nil || o.SessionTokenExchangeCode == nil { + var ret string + return ret + } + return *o.SessionTokenExchangeCode +} + +// GetSessionTokenExchangeCodeOk returns a tuple with the SessionTokenExchangeCode field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *RegistrationFlow) GetSessionTokenExchangeCodeOk() (*string, bool) { + if o == nil || o.SessionTokenExchangeCode == nil { + return nil, false + } + return o.SessionTokenExchangeCode, true +} + +// HasSessionTokenExchangeCode returns a boolean if a field has been set. +func (o *RegistrationFlow) HasSessionTokenExchangeCode() bool { + if o != nil && o.SessionTokenExchangeCode != nil { + return true + } + + return false +} + +// SetSessionTokenExchangeCode gets a reference to the given string and assigns it to the SessionTokenExchangeCode field. +func (o *RegistrationFlow) SetSessionTokenExchangeCode(v string) { + o.SessionTokenExchangeCode = &v +} + // GetTransientPayload returns the TransientPayload field value if set, zero value otherwise. func (o *RegistrationFlow) GetTransientPayload() map[string]interface{} { if o == nil || o.TransientPayload == nil { @@ -402,6 +436,9 @@ func (o RegistrationFlow) MarshalJSON() ([]byte, error) { if o.ReturnTo != nil { toSerialize["return_to"] = o.ReturnTo } + if o.SessionTokenExchangeCode != nil { + toSerialize["session_token_exchange_code"] = o.SessionTokenExchangeCode + } if o.TransientPayload != nil { toSerialize["transient_payload"] = o.TransientPayload } diff --git a/internal/client-go/model_successful_code_exchange_response.go b/internal/client-go/model_successful_code_exchange_response.go new file mode 100644 index 000000000000..9defabefefe5 --- /dev/null +++ b/internal/client-go/model_successful_code_exchange_response.go @@ -0,0 +1,144 @@ +/* + * Ory Identities API + * + * This is the API specification for Ory Identities with features such as registration, login, recovery, account verification, profile settings, password reset, identity management, session management, email and sms delivery, and more. + * + * API version: + * Contact: office@ory.sh + */ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package client + +import ( + "encoding/json" +) + +// SuccessfulCodeExchangeResponse The Response for Registration Flows via API +type SuccessfulCodeExchangeResponse struct { + Session Session `json:"session"` + // The Session Token A session token is equivalent to a session cookie, but it can be sent in the HTTP Authorization Header: Authorization: bearer ${session-token} The session token is only issued for API flows, not for Browser flows! + SessionToken *string `json:"session_token,omitempty"` +} + +// NewSuccessfulCodeExchangeResponse instantiates a new SuccessfulCodeExchangeResponse object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewSuccessfulCodeExchangeResponse(session Session) *SuccessfulCodeExchangeResponse { + this := SuccessfulCodeExchangeResponse{} + this.Session = session + return &this +} + +// NewSuccessfulCodeExchangeResponseWithDefaults instantiates a new SuccessfulCodeExchangeResponse object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewSuccessfulCodeExchangeResponseWithDefaults() *SuccessfulCodeExchangeResponse { + this := SuccessfulCodeExchangeResponse{} + return &this +} + +// GetSession returns the Session field value +func (o *SuccessfulCodeExchangeResponse) GetSession() Session { + if o == nil { + var ret Session + return ret + } + + return o.Session +} + +// GetSessionOk returns a tuple with the Session field value +// and a boolean to check if the value has been set. +func (o *SuccessfulCodeExchangeResponse) GetSessionOk() (*Session, bool) { + if o == nil { + return nil, false + } + return &o.Session, true +} + +// SetSession sets field value +func (o *SuccessfulCodeExchangeResponse) SetSession(v Session) { + o.Session = v +} + +// GetSessionToken returns the SessionToken field value if set, zero value otherwise. +func (o *SuccessfulCodeExchangeResponse) GetSessionToken() string { + if o == nil || o.SessionToken == nil { + var ret string + return ret + } + return *o.SessionToken +} + +// GetSessionTokenOk returns a tuple with the SessionToken field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *SuccessfulCodeExchangeResponse) GetSessionTokenOk() (*string, bool) { + if o == nil || o.SessionToken == nil { + return nil, false + } + return o.SessionToken, true +} + +// HasSessionToken returns a boolean if a field has been set. +func (o *SuccessfulCodeExchangeResponse) HasSessionToken() bool { + if o != nil && o.SessionToken != nil { + return true + } + + return false +} + +// SetSessionToken gets a reference to the given string and assigns it to the SessionToken field. +func (o *SuccessfulCodeExchangeResponse) SetSessionToken(v string) { + o.SessionToken = &v +} + +func (o SuccessfulCodeExchangeResponse) MarshalJSON() ([]byte, error) { + toSerialize := map[string]interface{}{} + if true { + toSerialize["session"] = o.Session + } + if o.SessionToken != nil { + toSerialize["session_token"] = o.SessionToken + } + return json.Marshal(toSerialize) +} + +type NullableSuccessfulCodeExchangeResponse struct { + value *SuccessfulCodeExchangeResponse + isSet bool +} + +func (v NullableSuccessfulCodeExchangeResponse) Get() *SuccessfulCodeExchangeResponse { + return v.value +} + +func (v *NullableSuccessfulCodeExchangeResponse) Set(val *SuccessfulCodeExchangeResponse) { + v.value = val + v.isSet = true +} + +func (v NullableSuccessfulCodeExchangeResponse) IsSet() bool { + return v.isSet +} + +func (v *NullableSuccessfulCodeExchangeResponse) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableSuccessfulCodeExchangeResponse(val *SuccessfulCodeExchangeResponse) *NullableSuccessfulCodeExchangeResponse { + return &NullableSuccessfulCodeExchangeResponse{value: val, isSet: true} +} + +func (v NullableSuccessfulCodeExchangeResponse) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableSuccessfulCodeExchangeResponse) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/internal/httpclient/.openapi-generator/FILES b/internal/httpclient/.openapi-generator/FILES index e64cec01a6a0..af0c731e5f92 100644 --- a/internal/httpclient/.openapi-generator/FILES +++ b/internal/httpclient/.openapi-generator/FILES @@ -76,6 +76,7 @@ docs/SessionAuthenticationMethod.md docs/SessionDevice.md docs/SettingsFlow.md docs/SettingsFlowState.md +docs/SuccessfulCodeExchangeResponse.md docs/SuccessfulNativeLogin.md docs/SuccessfulNativeRegistration.md docs/TokenPagination.md @@ -184,6 +185,7 @@ model_session_authentication_method.go model_session_device.go model_settings_flow.go model_settings_flow_state.go +model_successful_code_exchange_response.go model_successful_native_login.go model_successful_native_registration.go model_token_pagination.go diff --git a/internal/httpclient/README.md b/internal/httpclient/README.md index 7d9a781028ce..4549734d1e2c 100644 --- a/internal/httpclient/README.md +++ b/internal/httpclient/README.md @@ -94,6 +94,7 @@ Class | Method | HTTP request | Description *FrontendApi* | [**CreateNativeVerificationFlow**](docs/FrontendApi.md#createnativeverificationflow) | **Get** /self-service/verification/api | Create Verification Flow for Native Apps *FrontendApi* | [**DisableMyOtherSessions**](docs/FrontendApi.md#disablemyothersessions) | **Delete** /sessions | Disable my other sessions *FrontendApi* | [**DisableMySession**](docs/FrontendApi.md#disablemysession) | **Delete** /sessions/{id} | Disable one of my sessions +*FrontendApi* | [**ExchangeSessionToken**](docs/FrontendApi.md#exchangesessiontoken) | **Get** /self-service/exchange-code-for-session-token | Exchange Session Token *FrontendApi* | [**GetFlowError**](docs/FrontendApi.md#getflowerror) | **Get** /self-service/errors | Get User-Flow Errors *FrontendApi* | [**GetLoginFlow**](docs/FrontendApi.md#getloginflow) | **Get** /self-service/login/flows | Get Login Flow *FrontendApi* | [**GetRecoveryFlow**](docs/FrontendApi.md#getrecoveryflow) | **Get** /self-service/recovery/flows | Get Recovery Flow @@ -198,6 +199,7 @@ Class | Method | HTTP request | Description - [SessionDevice](docs/SessionDevice.md) - [SettingsFlow](docs/SettingsFlow.md) - [SettingsFlowState](docs/SettingsFlowState.md) + - [SuccessfulCodeExchangeResponse](docs/SuccessfulCodeExchangeResponse.md) - [SuccessfulNativeLogin](docs/SuccessfulNativeLogin.md) - [SuccessfulNativeRegistration](docs/SuccessfulNativeRegistration.md) - [TokenPagination](docs/TokenPagination.md) diff --git a/internal/httpclient/api_frontend.go b/internal/httpclient/api_frontend.go index d2db9f9b432e..e4fe9177eeea 100644 --- a/internal/httpclient/api_frontend.go +++ b/internal/httpclient/api_frontend.go @@ -387,6 +387,19 @@ type FrontendApi interface { */ DisableMySessionExecute(r FrontendApiApiDisableMySessionRequest) (*http.Response, error) + /* + * ExchangeSessionToken Exchange Session Token + * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + * @return FrontendApiApiExchangeSessionTokenRequest + */ + ExchangeSessionToken(ctx context.Context) FrontendApiApiExchangeSessionTokenRequest + + /* + * ExchangeSessionTokenExecute executes the request + * @return SuccessfulNativeLogin + */ + ExchangeSessionTokenExecute(r FrontendApiApiExchangeSessionTokenRequest) (*SuccessfulNativeLogin, *http.Response, error) + /* * GetFlowError Get User-Flow Errors * This endpoint returns the error associated with a user-facing self service errors. @@ -1844,11 +1857,13 @@ func (a *FrontendApiService) CreateBrowserVerificationFlowExecute(r FrontendApiA } type FrontendApiApiCreateNativeLoginFlowRequest struct { - ctx context.Context - ApiService FrontendApi - refresh *bool - aal *string - xSessionToken *string + ctx context.Context + ApiService FrontendApi + refresh *bool + aal *string + xSessionToken *string + enableSessionTokenExchangeCode *bool + returnTo *string } func (r FrontendApiApiCreateNativeLoginFlowRequest) Refresh(refresh bool) FrontendApiApiCreateNativeLoginFlowRequest { @@ -1863,6 +1878,14 @@ func (r FrontendApiApiCreateNativeLoginFlowRequest) XSessionToken(xSessionToken r.xSessionToken = &xSessionToken return r } +func (r FrontendApiApiCreateNativeLoginFlowRequest) EnableSessionTokenExchangeCode(enableSessionTokenExchangeCode bool) FrontendApiApiCreateNativeLoginFlowRequest { + r.enableSessionTokenExchangeCode = &enableSessionTokenExchangeCode + return r +} +func (r FrontendApiApiCreateNativeLoginFlowRequest) ReturnTo(returnTo string) FrontendApiApiCreateNativeLoginFlowRequest { + r.returnTo = &returnTo + return r +} func (r FrontendApiApiCreateNativeLoginFlowRequest) Execute() (*LoginFlow, *http.Response, error) { return r.ApiService.CreateNativeLoginFlowExecute(r) @@ -1931,6 +1954,12 @@ func (a *FrontendApiService) CreateNativeLoginFlowExecute(r FrontendApiApiCreate if r.aal != nil { localVarQueryParams.Add("aal", parameterToString(*r.aal, "")) } + if r.enableSessionTokenExchangeCode != nil { + localVarQueryParams.Add("enable_session_token_exchange_code", parameterToString(*r.enableSessionTokenExchangeCode, "")) + } + if r.returnTo != nil { + localVarQueryParams.Add("return_to", parameterToString(*r.returnTo, "")) + } // to determine the Content-Type header localVarHTTPContentTypes := []string{} @@ -2136,8 +2165,19 @@ func (a *FrontendApiService) CreateNativeRecoveryFlowExecute(r FrontendApiApiCre } type FrontendApiApiCreateNativeRegistrationFlowRequest struct { - ctx context.Context - ApiService FrontendApi + ctx context.Context + ApiService FrontendApi + enableSessionTokenExchangeCode *bool + returnTo *string +} + +func (r FrontendApiApiCreateNativeRegistrationFlowRequest) EnableSessionTokenExchangeCode(enableSessionTokenExchangeCode bool) FrontendApiApiCreateNativeRegistrationFlowRequest { + r.enableSessionTokenExchangeCode = &enableSessionTokenExchangeCode + return r +} +func (r FrontendApiApiCreateNativeRegistrationFlowRequest) ReturnTo(returnTo string) FrontendApiApiCreateNativeRegistrationFlowRequest { + r.returnTo = &returnTo + return r } func (r FrontendApiApiCreateNativeRegistrationFlowRequest) Execute() (*RegistrationFlow, *http.Response, error) { @@ -2200,6 +2240,12 @@ func (a *FrontendApiService) CreateNativeRegistrationFlowExecute(r FrontendApiAp localVarQueryParams := url.Values{} localVarFormParams := url.Values{} + if r.enableSessionTokenExchangeCode != nil { + localVarQueryParams.Add("enable_session_token_exchange_code", parameterToString(*r.enableSessionTokenExchangeCode, "")) + } + if r.returnTo != nil { + localVarQueryParams.Add("return_to", parameterToString(*r.returnTo, "")) + } // to determine the Content-Type header localVarHTTPContentTypes := []string{} @@ -2835,6 +2881,153 @@ func (a *FrontendApiService) DisableMySessionExecute(r FrontendApiApiDisableMySe return localVarHTTPResponse, nil } +type FrontendApiApiExchangeSessionTokenRequest struct { + ctx context.Context + ApiService FrontendApi + code *string +} + +func (r FrontendApiApiExchangeSessionTokenRequest) Code(code string) FrontendApiApiExchangeSessionTokenRequest { + r.code = &code + return r +} + +func (r FrontendApiApiExchangeSessionTokenRequest) Execute() (*SuccessfulNativeLogin, *http.Response, error) { + return r.ApiService.ExchangeSessionTokenExecute(r) +} + +/* + * ExchangeSessionToken Exchange Session Token + * @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + * @return FrontendApiApiExchangeSessionTokenRequest + */ +func (a *FrontendApiService) ExchangeSessionToken(ctx context.Context) FrontendApiApiExchangeSessionTokenRequest { + return FrontendApiApiExchangeSessionTokenRequest{ + ApiService: a, + ctx: ctx, + } +} + +/* + * Execute executes the request + * @return SuccessfulNativeLogin + */ +func (a *FrontendApiService) ExchangeSessionTokenExecute(r FrontendApiApiExchangeSessionTokenRequest) (*SuccessfulNativeLogin, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + localVarFormFileName string + localVarFileName string + localVarFileBytes []byte + localVarReturnValue *SuccessfulNativeLogin + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "FrontendApiService.ExchangeSessionToken") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/self-service/exchange-code-for-session-token" + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + if r.code == nil { + return localVarReturnValue, nil, reportError("code is required and must be specified") + } + + localVarQueryParams.Add("code", parameterToString(*r.code, "")) + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, localVarFormFileName, localVarFileName, localVarFileBytes) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 403 { + var v ErrorGeneric + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 404 { + var v ErrorGeneric + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 410 { + var v ErrorGeneric + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + var v ErrorGeneric + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + type FrontendApiApiGetFlowErrorRequest struct { ctx context.Context ApiService FrontendApi diff --git a/internal/httpclient/model_login_flow.go b/internal/httpclient/model_login_flow.go index d96b29852b48..1fc6fe2056a8 100644 --- a/internal/httpclient/model_login_flow.go +++ b/internal/httpclient/model_login_flow.go @@ -36,6 +36,8 @@ type LoginFlow struct { RequestedAal *AuthenticatorAssuranceLevel `json:"requested_aal,omitempty"` // ReturnTo contains the requested return_to URL. ReturnTo *string `json:"return_to,omitempty"` + // SessionTokenExchangeCode holds the secret code that the client can use to retrieve a session token after the login flow has been completed. This is only set if the client has requested a session token exchange code, and if the flow is of type \"api\", and only on creating the login flow. + SessionTokenExchangeCode *string `json:"session_token_exchange_code,omitempty"` // The flow type can either be `api` or `browser`. Type string `json:"type"` Ui UiContainer `json:"ui"` @@ -397,6 +399,38 @@ func (o *LoginFlow) SetReturnTo(v string) { o.ReturnTo = &v } +// GetSessionTokenExchangeCode returns the SessionTokenExchangeCode field value if set, zero value otherwise. +func (o *LoginFlow) GetSessionTokenExchangeCode() string { + if o == nil || o.SessionTokenExchangeCode == nil { + var ret string + return ret + } + return *o.SessionTokenExchangeCode +} + +// GetSessionTokenExchangeCodeOk returns a tuple with the SessionTokenExchangeCode field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *LoginFlow) GetSessionTokenExchangeCodeOk() (*string, bool) { + if o == nil || o.SessionTokenExchangeCode == nil { + return nil, false + } + return o.SessionTokenExchangeCode, true +} + +// HasSessionTokenExchangeCode returns a boolean if a field has been set. +func (o *LoginFlow) HasSessionTokenExchangeCode() bool { + if o != nil && o.SessionTokenExchangeCode != nil { + return true + } + + return false +} + +// SetSessionTokenExchangeCode gets a reference to the given string and assigns it to the SessionTokenExchangeCode field. +func (o *LoginFlow) SetSessionTokenExchangeCode(v string) { + o.SessionTokenExchangeCode = &v +} + // GetType returns the Type field value func (o *LoginFlow) GetType() string { if o == nil { @@ -512,6 +546,9 @@ func (o LoginFlow) MarshalJSON() ([]byte, error) { if o.ReturnTo != nil { toSerialize["return_to"] = o.ReturnTo } + if o.SessionTokenExchangeCode != nil { + toSerialize["session_token_exchange_code"] = o.SessionTokenExchangeCode + } if true { toSerialize["type"] = o.Type } diff --git a/internal/httpclient/model_registration_flow.go b/internal/httpclient/model_registration_flow.go index 0cf620898a8c..a28af14f1cc4 100644 --- a/internal/httpclient/model_registration_flow.go +++ b/internal/httpclient/model_registration_flow.go @@ -31,6 +31,8 @@ type RegistrationFlow struct { RequestUrl string `json:"request_url"` // ReturnTo contains the requested return_to URL. ReturnTo *string `json:"return_to,omitempty"` + // SessionTokenExchangeCode holds the secret code that the client can use to retrieve a session token after the flow has been completed. This is only set if the client has requested a session token exchange code, and if the flow is of type \"api\", and only on creating the flow. + SessionTokenExchangeCode *string `json:"session_token_exchange_code,omitempty"` // TransientPayload is used to pass data from the registration to a webhook TransientPayload map[string]interface{} `json:"transient_payload,omitempty"` // The flow type can either be `api` or `browser`. @@ -296,6 +298,38 @@ func (o *RegistrationFlow) SetReturnTo(v string) { o.ReturnTo = &v } +// GetSessionTokenExchangeCode returns the SessionTokenExchangeCode field value if set, zero value otherwise. +func (o *RegistrationFlow) GetSessionTokenExchangeCode() string { + if o == nil || o.SessionTokenExchangeCode == nil { + var ret string + return ret + } + return *o.SessionTokenExchangeCode +} + +// GetSessionTokenExchangeCodeOk returns a tuple with the SessionTokenExchangeCode field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *RegistrationFlow) GetSessionTokenExchangeCodeOk() (*string, bool) { + if o == nil || o.SessionTokenExchangeCode == nil { + return nil, false + } + return o.SessionTokenExchangeCode, true +} + +// HasSessionTokenExchangeCode returns a boolean if a field has been set. +func (o *RegistrationFlow) HasSessionTokenExchangeCode() bool { + if o != nil && o.SessionTokenExchangeCode != nil { + return true + } + + return false +} + +// SetSessionTokenExchangeCode gets a reference to the given string and assigns it to the SessionTokenExchangeCode field. +func (o *RegistrationFlow) SetSessionTokenExchangeCode(v string) { + o.SessionTokenExchangeCode = &v +} + // GetTransientPayload returns the TransientPayload field value if set, zero value otherwise. func (o *RegistrationFlow) GetTransientPayload() map[string]interface{} { if o == nil || o.TransientPayload == nil { @@ -402,6 +436,9 @@ func (o RegistrationFlow) MarshalJSON() ([]byte, error) { if o.ReturnTo != nil { toSerialize["return_to"] = o.ReturnTo } + if o.SessionTokenExchangeCode != nil { + toSerialize["session_token_exchange_code"] = o.SessionTokenExchangeCode + } if o.TransientPayload != nil { toSerialize["transient_payload"] = o.TransientPayload } diff --git a/internal/httpclient/model_successful_code_exchange_response.go b/internal/httpclient/model_successful_code_exchange_response.go new file mode 100644 index 000000000000..9defabefefe5 --- /dev/null +++ b/internal/httpclient/model_successful_code_exchange_response.go @@ -0,0 +1,144 @@ +/* + * Ory Identities API + * + * This is the API specification for Ory Identities with features such as registration, login, recovery, account verification, profile settings, password reset, identity management, session management, email and sms delivery, and more. + * + * API version: + * Contact: office@ory.sh + */ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package client + +import ( + "encoding/json" +) + +// SuccessfulCodeExchangeResponse The Response for Registration Flows via API +type SuccessfulCodeExchangeResponse struct { + Session Session `json:"session"` + // The Session Token A session token is equivalent to a session cookie, but it can be sent in the HTTP Authorization Header: Authorization: bearer ${session-token} The session token is only issued for API flows, not for Browser flows! + SessionToken *string `json:"session_token,omitempty"` +} + +// NewSuccessfulCodeExchangeResponse instantiates a new SuccessfulCodeExchangeResponse object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewSuccessfulCodeExchangeResponse(session Session) *SuccessfulCodeExchangeResponse { + this := SuccessfulCodeExchangeResponse{} + this.Session = session + return &this +} + +// NewSuccessfulCodeExchangeResponseWithDefaults instantiates a new SuccessfulCodeExchangeResponse object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewSuccessfulCodeExchangeResponseWithDefaults() *SuccessfulCodeExchangeResponse { + this := SuccessfulCodeExchangeResponse{} + return &this +} + +// GetSession returns the Session field value +func (o *SuccessfulCodeExchangeResponse) GetSession() Session { + if o == nil { + var ret Session + return ret + } + + return o.Session +} + +// GetSessionOk returns a tuple with the Session field value +// and a boolean to check if the value has been set. +func (o *SuccessfulCodeExchangeResponse) GetSessionOk() (*Session, bool) { + if o == nil { + return nil, false + } + return &o.Session, true +} + +// SetSession sets field value +func (o *SuccessfulCodeExchangeResponse) SetSession(v Session) { + o.Session = v +} + +// GetSessionToken returns the SessionToken field value if set, zero value otherwise. +func (o *SuccessfulCodeExchangeResponse) GetSessionToken() string { + if o == nil || o.SessionToken == nil { + var ret string + return ret + } + return *o.SessionToken +} + +// GetSessionTokenOk returns a tuple with the SessionToken field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *SuccessfulCodeExchangeResponse) GetSessionTokenOk() (*string, bool) { + if o == nil || o.SessionToken == nil { + return nil, false + } + return o.SessionToken, true +} + +// HasSessionToken returns a boolean if a field has been set. +func (o *SuccessfulCodeExchangeResponse) HasSessionToken() bool { + if o != nil && o.SessionToken != nil { + return true + } + + return false +} + +// SetSessionToken gets a reference to the given string and assigns it to the SessionToken field. +func (o *SuccessfulCodeExchangeResponse) SetSessionToken(v string) { + o.SessionToken = &v +} + +func (o SuccessfulCodeExchangeResponse) MarshalJSON() ([]byte, error) { + toSerialize := map[string]interface{}{} + if true { + toSerialize["session"] = o.Session + } + if o.SessionToken != nil { + toSerialize["session_token"] = o.SessionToken + } + return json.Marshal(toSerialize) +} + +type NullableSuccessfulCodeExchangeResponse struct { + value *SuccessfulCodeExchangeResponse + isSet bool +} + +func (v NullableSuccessfulCodeExchangeResponse) Get() *SuccessfulCodeExchangeResponse { + return v.value +} + +func (v *NullableSuccessfulCodeExchangeResponse) Set(val *SuccessfulCodeExchangeResponse) { + v.value = val + v.isSet = true +} + +func (v NullableSuccessfulCodeExchangeResponse) IsSet() bool { + return v.isSet +} + +func (v *NullableSuccessfulCodeExchangeResponse) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableSuccessfulCodeExchangeResponse(val *SuccessfulCodeExchangeResponse) *NullableSuccessfulCodeExchangeResponse { + return &NullableSuccessfulCodeExchangeResponse{value: val, isSet: true} +} + +func (v NullableSuccessfulCodeExchangeResponse) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableSuccessfulCodeExchangeResponse) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/persistence/reference.go b/persistence/reference.go index 1ab56c56f909..215ceb4a7f3f 100644 --- a/persistence/reference.go +++ b/persistence/reference.go @@ -7,6 +7,7 @@ import ( "context" "time" + "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/x/networkx" "github.com/gofrs/uuid" @@ -42,6 +43,7 @@ type Persister interface { settings.FlowPersister courier.Persister session.Persister + sessiontokenexchange.Persister errorx.Persister verification.FlowPersister recovery.FlowPersister diff --git a/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.down.sql b/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.down.sql new file mode 100644 index 000000000000..25dfb10cf2cd --- /dev/null +++ b/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.down.sql @@ -0,0 +1 @@ +DROP TABLE session_token_exchanges; diff --git a/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.mysql.down.sql b/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.mysql.down.sql new file mode 100644 index 000000000000..25dfb10cf2cd --- /dev/null +++ b/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.mysql.down.sql @@ -0,0 +1 @@ +DROP TABLE session_token_exchanges; diff --git a/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.mysql.up.sql b/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.mysql.up.sql new file mode 100644 index 000000000000..01e3f6f0a6de --- /dev/null +++ b/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.mysql.up.sql @@ -0,0 +1,15 @@ +CREATE TABLE session_token_exchanges ( + id CHAR(36) NOT NULL PRIMARY KEY, + nid CHAR(36) NOT NULL, + flow_id CHAR(36) NOT NULL, + session_id CHAR(36) DEFAULT NULL, + code VARCHAR(64) NOT NULL, + + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Relevant query: +-- SELECT * from session_token_exchanges +-- WHERE flow_id = ? AND nid = ? AND code = ? AND session_id IS NOT NULL AND code <> ''; +CREATE INDEX session_token_exchanges_nid_flow_id_code_idx ON session_token_exchanges (nid, flow_id, code); diff --git a/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.up.sql b/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.up.sql new file mode 100644 index 000000000000..65f2b5b230e5 --- /dev/null +++ b/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.up.sql @@ -0,0 +1,15 @@ +CREATE TABLE session_token_exchanges ( + "id" UUID NOT NULL PRIMARY KEY, + "nid" UUID NOT NULL, + "flow_id" UUID NOT NULL, + "session_id" UUID DEFAULT NULL, + "code" VARCHAR(64) NOT NULL, + + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL +); + +-- Relevant query: +-- SELECT * from session_token_exchanges +-- WHERE flow_id = ? AND nid = ? AND code = ? AND session_id IS NOT NULL AND code <> ''; +CREATE INDEX session_token_exchanges_nid_flow_id_code_idx ON session_token_exchanges (nid, flow_id, code); diff --git a/persistence/sql/persister_sessiontokenexchanger.go b/persistence/sql/persister_sessiontokenexchanger.go new file mode 100644 index 000000000000..a7a117385986 --- /dev/null +++ b/persistence/sql/persister_sessiontokenexchanger.go @@ -0,0 +1,86 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package sql + +import ( + "context" + "fmt" + + "github.com/gofrs/uuid" + "github.com/pkg/errors" + + "github.com/ory/kratos/selfservice/sessiontokenexchange" + "github.com/ory/x/otelx" + "github.com/ory/x/sqlcon" +) + +var _ sessiontokenexchange.Persister = new(Persister) + +func (p *Persister) CreateSessionTokenExchanger(ctx context.Context, flowID uuid.UUID, code string) (err error) { + ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.CreateSessionTokenExchanger") + defer otelx.End(span, &err) + + e := sessiontokenexchange.Exchanger{ + NID: p.NetworkID(ctx), + FlowID: flowID, + Code: code, + } + + return p.GetConnection(ctx).Create(&e) +} + +func (p *Persister) GetExchangerFromCode(ctx context.Context, code string) (e *sessiontokenexchange.Exchanger, err error) { + ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.GetExchangerFromCode") + defer otelx.End(span, &err) + + e = new(sessiontokenexchange.Exchanger) + conn := p.GetConnection(ctx) + if err = conn.Where( + "nid = ? AND code = ? AND session_id IS NOT NULL AND code <> ''", + p.NetworkID(ctx), code).First(e); err != nil { + return nil, sqlcon.HandleError(err) + } + + return e, nil +} + +func (p *Persister) UpdateSessionOnExchanger(ctx context.Context, flowID uuid.UUID, sessionID uuid.UUID) (err error) { + ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.UpdateSessionOnExchanger") + defer otelx.End(span, &err) + + conn := p.GetConnection(ctx) + query := fmt.Sprintf("UPDATE %s SET session_id = ? WHERE flow_id = ? AND nid = ?", + conn.Dialect.Quote(new(sessiontokenexchange.Exchanger).TableName()), + ) + + return sqlcon.HandleError(conn.RawQuery(query, sessionID, flowID, p.NetworkID(ctx)).Exec()) +} + +func (p *Persister) CodeExistsForFlow(ctx context.Context, flowID uuid.UUID) (found bool, err error) { + ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.CodeExistsForFlow") + defer otelx.End(span, &err) + + switch err = sqlcon.HandleError(p.GetConnection(ctx). + Where("flow_id = ? AND nid = ? AND code <> ''", flowID, p.NetworkID(ctx)). + First(&sessiontokenexchange.Exchanger{})); { + case err == nil: + return true, nil + case errors.Is(err, sqlcon.ErrNoRows): + return false, nil + default: + return false, err + } +} + +func (p *Persister) MoveToNewFlow(ctx context.Context, oldFlow, newFlow uuid.UUID) (err error) { + ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.MoveToNewFlow") + defer otelx.End(span, &err) + + conn := p.GetConnection(ctx) + query := fmt.Sprintf("UPDATE %s SET flow_id = ? WHERE flow_id = ? AND nid = ?", + conn.Dialect.Quote(new(sessiontokenexchange.Exchanger).TableName()), + ) + + return sqlcon.HandleError(conn.RawQuery(query, newFlow, oldFlow, p.NetworkID(ctx)).Exec()) +} diff --git a/persistence/sql/persister_test.go b/persistence/sql/persister_test.go index 94bdc5d74dca..548df5268cc7 100644 --- a/persistence/sql/persister_test.go +++ b/persistence/sql/persister_test.go @@ -47,6 +47,7 @@ import ( registration "github.com/ory/kratos/selfservice/flow/registration/test" settings "github.com/ory/kratos/selfservice/flow/settings/test" verification "github.com/ory/kratos/selfservice/flow/verification/test" + sessiontokenexchange "github.com/ory/kratos/selfservice/sessiontokenexchange/test" code "github.com/ory/kratos/selfservice/strategy/code/test" link "github.com/ory/kratos/selfservice/strategy/link/test" session "github.com/ory/kratos/session/test" @@ -241,6 +242,10 @@ func TestPersister(t *testing.T) { pop.SetLogger(pl(t)) session.TestPersister(ctx, conf, p)(t) }) + t.Run("contract=sessiontokenexchange.TestPersister", func(t *testing.T) { + pop.SetLogger(pl(t)) + sessiontokenexchange.TestPersister(ctx, conf, p)(t) + }) t.Run("contract=courier.TestPersister", func(t *testing.T) { pop.SetLogger(pl(t)) upsert, insert := sqltesthelpers.DefaultNetworkWrapper(p) diff --git a/selfservice/flow/flow.go b/selfservice/flow/flow.go index e8e095bcc44c..6759ee3dfda7 100644 --- a/selfservice/flow/flow.go +++ b/selfservice/flow/flow.go @@ -4,11 +4,13 @@ package flow import ( + "context" "net/http" "net/url" "github.com/pkg/errors" + "github.com/ory/kratos/driver/config" "github.com/ory/kratos/ui/container" "github.com/ory/herodot" @@ -38,3 +40,7 @@ type Flow interface { AppendTo(*url.URL) *url.URL GetUI() *container.Container } + +type FlowWithRedirect interface { + SecureRedirectToOpts(ctx context.Context, cfg config.Provider) (opts []x.SecureRedirectOption) +} diff --git a/selfservice/flow/login/error.go b/selfservice/flow/login/error.go index 9d0dec08fdc6..460b75abe1de 100644 --- a/selfservice/flow/login/error.go +++ b/selfservice/flow/login/error.go @@ -6,6 +6,7 @@ package login import ( "net/http" + "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/kratos/ui/node" "github.com/ory/kratos/selfservice/flow" @@ -38,6 +39,7 @@ type ( x.WriterProvider x.LoggingProvider config.Provider + sessiontokenexchange.PersistenceProvider FlowPersistenceProvider HandlerProvider @@ -118,6 +120,11 @@ func (s *ErrorHandler) WriteFlowError(w http.ResponseWriter, r *http.Request, f return } + if hasCode, _ := s.d.SessionTokenExchangePersister().CodeExistsForFlow(r.Context(), f.ID); f.Type == flow.TypeAPI && hasCode { + http.Redirect(w, r, f.ReturnTo, http.StatusSeeOther) + return + } + updatedFlow, innerErr := s.d.LoginFlowPersister().GetLoginFlow(r.Context(), f.ID) if innerErr != nil { s.forward(w, r, updatedFlow, innerErr) diff --git a/selfservice/flow/login/flow.go b/selfservice/flow/login/flow.go index 1dd3684734d8..2401a5256dd7 100644 --- a/selfservice/flow/login/flow.go +++ b/selfservice/flow/login/flow.go @@ -119,6 +119,11 @@ type Flow struct { // // This value can be one of "aal1", "aal2", "aal3". RequestedAAL identity.AuthenticatorAssuranceLevel `json:"requested_aal" faker:"len=4" db:"requested_aal"` + + // SessionTokenExchangeCode holds the secret code that the client can use to retrieve a session token after the login flow has been completed. + // This is only set if the client has requested a session token exchange code, and if the flow is of type "api", + // and only on creating the login flow. + SessionTokenExchangeCode string `json:"session_token_exchange_code,omitempty" faker:"-" db:"-"` } func NewFlow(conf *config.Config, exp time.Duration, csrf string, r *http.Request, flowType flow.Type) (*Flow, error) { @@ -236,3 +241,13 @@ func (f *Flow) AfterSave(*pop.Connection) error { func (f *Flow) GetUI() *container.Container { return f.UI } + +func (f *Flow) SecureRedirectToOpts(ctx context.Context, cfg config.Provider) (opts []x.SecureRedirectOption) { + return []x.SecureRedirectOption{ + x.SecureRedirectReturnTo(f.ReturnTo), + x.SecureRedirectUseSourceURL(f.RequestURL), + x.SecureRedirectAllowURLs(cfg.Config().SelfServiceBrowserAllowedReturnToDomains(ctx)), + x.SecureRedirectAllowSelfServiceURLs(cfg.Config().SelfPublicURL(ctx)), + x.SecureRedirectOverrideDefaultReturnTo(cfg.Config().SelfServiceFlowLoginReturnTo(ctx, f.Active.String())), + } +} diff --git a/selfservice/flow/login/handler.go b/selfservice/flow/login/handler.go index 90fd2bed2231..f4ed0d8b56e1 100644 --- a/selfservice/flow/login/handler.go +++ b/selfservice/flow/login/handler.go @@ -12,9 +12,10 @@ import ( "github.com/ory/herodot" hydraclientgo "github.com/ory/hydra-client-go/v2" - "github.com/ory/kratos/hydra" + "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/kratos/text" + "github.com/ory/x/randx" "github.com/ory/x/stringsx" "github.com/ory/nosurf" @@ -27,13 +28,12 @@ import ( "github.com/julienschmidt/httprouter" "github.com/pkg/errors" - "github.com/ory/x/urlx" - "github.com/ory/kratos/driver/config" "github.com/ory/kratos/selfservice/errorx" "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/session" "github.com/ory/kratos/x" + "github.com/ory/x/urlx" ) const ( @@ -43,6 +43,8 @@ const ( RouteGetFlow = "/self-service/login/flows" RouteSubmitFlow = "/self-service/login" + + RouteExchangeSessionToken = "/self-service/login/exchange-session-token" //nolint:gosec ) type ( @@ -59,6 +61,7 @@ type ( x.CSRFProvider config.Provider ErrorHandlerProvider + sessiontokenexchange.PersistenceProvider } HandlerProvider interface { LoginHandler() *Handler @@ -133,6 +136,17 @@ func (h *Handler) NewLoginFlow(w http.ResponseWriter, r *http.Request, ft flow.T return nil, nil, errors.WithStack(herodot.ErrBadRequest.WithReasonf("Unable to parse AuthenticationMethod Assurance Level (AAL): %s", cs.ToUnknownCaseErr())) } + if ft == flow.TypeAPI && r.URL.Query().Get("enable_session_token_exchange_code") == "true" { + // Panicing here is ok since it will return a 500 to the user, which is accurate for when + // we can't generate a random string. + f.SessionTokenExchangeCode = randx.MustString(64, randx.AlphaNum) + + err = h.d.SessionTokenExchangePersister().CreateSessionTokenExchanger(r.Context(), f.ID, f.SessionTokenExchangeCode) + if err != nil { + return nil, nil, errors.WithStack(herodot.ErrInternalServerError.WithWrap(err)) + } + } + // We assume an error means the user has no session sess, err := h.d.SessionManager().FetchFromRequest(r.Context(), r) if e := new(session.ErrNoActiveSessionFound); errors.As(err, &e) { @@ -250,6 +264,17 @@ type createNativeLoginFlow struct { // // in: header SessionToken string `json:"X-Session-Token"` + + // EnableSessionTokenExchangeCode requests the login flow to include a code that can be used to retrieve the session token + // after the login flow has been completed. + // + // in: query + EnableSessionTokenExchangeCode bool `json:"enable_session_token_exchange_code"` + + // The URL to return the browser to after the flow was completed. + // + // in: query + ReturnTo string `json:"return_to"` } // swagger:route GET /self-service/login/api frontend createNativeLoginFlow diff --git a/selfservice/flow/login/handler_test.go b/selfservice/flow/login/handler_test.go index 53f534dd5feb..e2eee8623851 100644 --- a/selfservice/flow/login/handler_test.go +++ b/selfservice/flow/login/handler_test.go @@ -392,6 +392,13 @@ func TestFlowLifecycle(t *testing.T) { res, body := initFlow(t, url.Values{}, true) assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow) assertion(body, false, true) + assert.Empty(t, gjson.GetBytes(body, "session_token_exchange_code").String()) + }) + + t.Run("case=returns session exchange code", func(t *testing.T) { + res, body := initFlow(t, urlx.ParseOrPanic("/?enable_session_token_exchange_code=true").Query(), true) + assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow) + assert.NotEmpty(t, gjson.GetBytes(body, "session_token_exchange_code").String()) }) t.Run("case=can not request refresh and aal at the same time on unauthenticated request", func(t *testing.T) { @@ -470,6 +477,12 @@ func TestFlowLifecycle(t *testing.T) { assert.Contains(t, res.Request.URL.String(), loginTS.URL) }) + t.Run("case=never returns a session token exchange code", func(t *testing.T) { + _, body := initFlow(t, urlx.ParseOrPanic("/?enable_session_token_exchange_code=true").Query(), false) + assertion(body, false, false) + assert.Empty(t, gjson.GetBytes(body, "session_token_exchange_code").String()) + }) + t.Run("case=can not request refresh and aal at the same time on unauthenticated request", func(t *testing.T) { res, body := initFlow(t, url.Values{"refresh": {"true"}, "aal": {"aal2"}}, false) assert.Contains(t, res.Request.URL.String(), errorTS.URL) diff --git a/selfservice/flow/login/hook.go b/selfservice/flow/login/hook.go index 7517a0107f64..41485666eaef 100644 --- a/selfservice/flow/login/hook.go +++ b/selfservice/flow/login/hook.go @@ -17,6 +17,7 @@ import ( "github.com/ory/kratos/hydra" "github.com/ory/kratos/identity" "github.com/ory/kratos/selfservice/flow" + "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/kratos/session" "github.com/ory/kratos/ui/container" "github.com/ory/kratos/ui/node" @@ -51,6 +52,7 @@ type ( x.WriterProvider x.LoggingProvider x.TracingProvider + sessiontokenexchange.PersistenceProvider HooksProvider } @@ -188,6 +190,14 @@ func (e *HookExecutor) PostLoginHook(w http.ResponseWriter, r *http.Request, g n ), ) + if ok, _ := e.d.SessionTokenExchangePersister().CodeExistsForFlow(ctx, a.ID); ok { + if err = e.d.SessionTokenExchangePersister().UpdateSessionOnExchanger(r.Context(), a.ID, s.ID); err != nil { + return errors.WithStack(err) + } + http.Redirect(w, r, returnTo.String(), http.StatusFound) + return nil + } + response := &APIFlowResponse{Session: s, Token: s.Token} if required, _ := e.requiresAAL2(r, classified, a); required { // If AAL is not satisfied, we omit the identity to preserve the user's privacy in case of a phishing attack. diff --git a/selfservice/flow/login/hook_test.go b/selfservice/flow/login/hook_test.go index adfa477c2df0..5e891e3fd9ae 100644 --- a/selfservice/flow/login/hook_test.go +++ b/selfservice/flow/login/hook_test.go @@ -56,7 +56,7 @@ func TestLoginExecutor(t *testing.T) { router.GET("/login/post", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { a, err := login.NewFlow(conf, time.Minute, "", r, ft) require.NoError(t, err) - a.Active = identity.CredentialsType(strategy) + a.Active = strategy a.RequestURL = x.RequestURL(r).String() sess := session.NewInactiveSession() sess.CompletedLoginFor(identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) diff --git a/selfservice/flow/registration/error.go b/selfservice/flow/registration/error.go index ae01ca2850b4..73b3b0a9657c 100644 --- a/selfservice/flow/registration/error.go +++ b/selfservice/flow/registration/error.go @@ -7,6 +7,7 @@ import ( "net/http" "github.com/ory/kratos/schema" + "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/kratos/ui/node" "github.com/ory/kratos/selfservice/flow" @@ -35,6 +36,7 @@ type ( x.LoggingProvider config.Provider + sessiontokenexchange.PersistenceProvider FlowPersistenceProvider HandlerProvider } @@ -129,6 +131,10 @@ func (s *ErrorHandler) WriteFlowError( http.Redirect(w, r, f.AppendTo(s.d.Config().SelfServiceFlowRegistrationUI(r.Context())).String(), http.StatusFound) return } + if hasCode, _ := s.d.SessionTokenExchangePersister().CodeExistsForFlow(r.Context(), f.ID); f.Type == flow.TypeAPI && hasCode { + http.Redirect(w, r, f.ReturnTo, http.StatusSeeOther) + return + } updatedFlow, innerErr := s.d.RegistrationFlowPersister().GetRegistrationFlow(r.Context(), f.ID) if innerErr != nil { diff --git a/selfservice/flow/registration/flow.go b/selfservice/flow/registration/flow.go index 6625f831d219..da6eb07df18f 100644 --- a/selfservice/flow/registration/flow.go +++ b/selfservice/flow/registration/flow.go @@ -110,6 +110,11 @@ type Flow struct { // It can, for example, contain a reference to the verification flow, created as part of the user's // registration. ContinueWithItems []flow.ContinueWith `json:"-" db:"-" faker:"-" ` + + // SessionTokenExchangeCode holds the secret code that the client can use to retrieve a session token after the flow has been completed. + // This is only set if the client has requested a session token exchange code, and if the flow is of type "api", + // and only on creating the flow. + SessionTokenExchangeCode string `json:"session_token_exchange_code,omitempty" faker:"-" db:"-"` } func NewFlow(conf *config.Config, exp time.Duration, csrf string, r *http.Request, ft flow.Type) (*Flow, error) { @@ -223,3 +228,13 @@ func (f *Flow) AddContinueWith(c flow.ContinueWith) { func (f *Flow) ContinueWith() []flow.ContinueWith { return f.ContinueWithItems } + +func (f *Flow) SecureRedirectToOpts(ctx context.Context, cfg config.Provider) (opts []x.SecureRedirectOption) { + return []x.SecureRedirectOption{ + x.SecureRedirectReturnTo(f.ReturnTo), + x.SecureRedirectUseSourceURL(f.RequestURL), + x.SecureRedirectAllowURLs(cfg.Config().SelfServiceBrowserAllowedReturnToDomains(ctx)), + x.SecureRedirectAllowSelfServiceURLs(cfg.Config().SelfPublicURL(ctx)), + x.SecureRedirectOverrideDefaultReturnTo(cfg.Config().SelfServiceFlowRegistrationReturnTo(ctx, f.Active.String())), + } +} diff --git a/selfservice/flow/registration/handler.go b/selfservice/flow/registration/handler.go index 28eef43bbeef..a14d60f1822e 100644 --- a/selfservice/flow/registration/handler.go +++ b/selfservice/flow/registration/handler.go @@ -8,8 +8,11 @@ import ( "net/url" "time" + "github.com/ory/herodot" "github.com/ory/kratos/hydra" + "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/kratos/text" + "github.com/ory/x/randx" "github.com/ory/nosurf" @@ -54,6 +57,7 @@ type ( HookExecutorProvider FlowPersistenceProvider ErrorHandlerProvider + sessiontokenexchange.PersistenceProvider } HandlerProvider interface { RegistrationHandler() *Handler @@ -119,6 +123,17 @@ func (h *Handler) NewRegistrationFlow(w http.ResponseWriter, r *http.Request, ft o(f) } + if ft == flow.TypeAPI && r.URL.Query().Get("enable_session_token_exchange_code") == "true" { + // Panicing here is ok since it will return a 500 to the user, which is accurate for when + // we can't generate a random string. + f.SessionTokenExchangeCode = randx.MustString(64, randx.AlphaNum) + + err = h.d.SessionTokenExchangePersister().CreateSessionTokenExchanger(r.Context(), f.ID, f.SessionTokenExchangeCode) + if err != nil { + return nil, errors.WithStack(herodot.ErrInternalServerError.WithWrap(err)) + } + } + for _, s := range h.d.RegistrationStrategies(r.Context()) { if err := s.PopulateRegistrationMethod(r, f); err != nil { return nil, err @@ -195,6 +210,25 @@ func (h *Handler) createNativeRegistrationFlow(w http.ResponseWriter, r *http.Re h.d.Writer().Write(w, r, a) } +// Create Native Registration Flow Parameters +// +// swagger:parameters createNativeRegistrationFlow +// +//nolint:deadcode,unused +//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions +type createNativeRegistrationFlow struct { + // EnableSessionTokenExchangeCode requests the login flow to include a code that can be used to retrieve the session token + // after the login flow has been completed. + // + // in: query + EnableSessionTokenExchangeCode bool `json:"enable_session_token_exchange_code"` + + // The URL to return the browser to after the flow was completed. + // + // in: query + ReturnTo string `json:"return_to"` +} + // Create Browser Registration Flow Parameters // // swagger:parameters createBrowserRegistrationFlow diff --git a/selfservice/flow/registration/handler_test.go b/selfservice/flow/registration/handler_test.go index 4137188b990d..15f5e883a77a 100644 --- a/selfservice/flow/registration/handler_test.go +++ b/selfservice/flow/registration/handler_test.go @@ -17,6 +17,7 @@ import ( "github.com/gofrs/uuid" "github.com/ory/kratos/corpx" + "github.com/ory/x/urlx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -108,13 +109,13 @@ func TestInitFlow(t *testing.T) { return res, body } - initFlowWithAccept := func(t *testing.T, isAPI bool, accept string) (*http.Response, []byte) { + initFlowWithAccept := func(t *testing.T, query url.Values, isAPI bool, accept string) (*http.Response, []byte) { route := registration.RouteInitBrowserFlow if isAPI { route = registration.RouteInitAPIFlow } c := publicTS.Client() - req, err := http.NewRequest("GET", publicTS.URL+route, nil) + req, err := http.NewRequest("GET", publicTS.URL+route+"?"+query.Encode(), nil) require.NoError(t, err) if accept != "" { req.Header.Set("Accept", accept) @@ -128,19 +129,27 @@ func TestInitFlow(t *testing.T) { return res, body } - initFlow := func(t *testing.T, isAPI bool) (*http.Response, []byte) { - return initFlowWithAccept(t, isAPI, "") + initFlow := func(t *testing.T, query url.Values, isAPI bool) (*http.Response, []byte) { + return initFlowWithAccept(t, query, isAPI, "") } initSPAFlow := func(t *testing.T) (*http.Response, []byte) { - return initFlowWithAccept(t, false, "application/json") + return initFlowWithAccept(t, url.Values{}, false, "application/json") } t.Run("flow=api", func(t *testing.T) { t.Run("case=creates a new flow on unauthenticated request", func(t *testing.T) { - res, body := initFlow(t, true) + res, body := initFlow(t, url.Values{}, true) + assert.Contains(t, res.Request.URL.String(), registration.RouteInitAPIFlow) + assertion(body, false, true) + assert.Empty(t, gjson.GetBytes(body, "session_token_exchange_code").String()) + }) + + t.Run("case=returns a session token exchange code", func(t *testing.T) { + res, body := initFlow(t, urlx.ParseOrPanic("/?enable_session_token_exchange_code=true").Query(), true) assert.Contains(t, res.Request.URL.String(), registration.RouteInitAPIFlow) assertion(body, false, true) + assert.NotEmpty(t, gjson.GetBytes(body, "session_token_exchange_code").String()) }) t.Run("case=fails on authenticated request", func(t *testing.T) { @@ -152,9 +161,16 @@ func TestInitFlow(t *testing.T) { t.Run("flow=browser", func(t *testing.T) { t.Run("case=does not set forced flag on unauthenticated request", func(t *testing.T) { - res, body := initFlow(t, false) + res, body := initFlow(t, url.Values{}, false) assertion(body, false, false) assert.Contains(t, res.Request.URL.String(), registrationTS.URL) + assert.Empty(t, gjson.GetBytes(body, "session_token_exchange_code").String()) + }) + + t.Run("case=never returns a session token exchange code", func(t *testing.T) { + _, body := initFlow(t, urlx.ParseOrPanic("/?enable_session_token_exchange_code=true").Query(), false) + assertion(body, false, false) + assert.Empty(t, gjson.GetBytes(body, "session_token_exchange_code").String()) }) t.Run("case=makes request with JSON", func(t *testing.T) { diff --git a/selfservice/flow/registration/hook.go b/selfservice/flow/registration/hook.go index 5c77f0d5f020..e9857b64900e 100644 --- a/selfservice/flow/registration/hook.go +++ b/selfservice/flow/registration/hook.go @@ -13,6 +13,7 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" + "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/x/httpx" "github.com/ory/x/otelx/semconv" "github.com/ory/x/sqlcon" @@ -81,6 +82,7 @@ type ( x.HTTPClientProvider x.LoggingProvider x.WriterProvider + sessiontokenexchange.PersistenceProvider } HookExecutor struct { d executorDependencies @@ -233,6 +235,14 @@ func (e *HookExecutor) PostRegistrationHook(w http.ResponseWriter, r *http.Reque Debug("Post registration execution hooks completed successfully.") if a.Type == flow.TypeAPI || x.IsJSONRequest(r) { + if ok, _ := e.d.SessionTokenExchangePersister().CodeExistsForFlow(r.Context(), a.ID); ok { + if err = e.d.SessionTokenExchangePersister().UpdateSessionOnExchanger(r.Context(), a.ID, s.ID); err != nil { + return errors.WithStack(err) + } + http.Redirect(w, r, returnTo.String(), http.StatusFound) + return nil + } + e.d.Writer().Write(w, r, &APIFlowResponse{ Identity: i, ContinueWith: a.ContinueWith(), diff --git a/selfservice/hook/session_issuer.go b/selfservice/hook/session_issuer.go index 4aed85284cb8..024e68a645d7 100644 --- a/selfservice/hook/session_issuer.go +++ b/selfservice/hook/session_issuer.go @@ -10,8 +10,10 @@ import ( "github.com/pkg/errors" + "github.com/ory/kratos/driver/config" "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/registration" + "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/kratos/session" "github.com/ory/kratos/x" "github.com/ory/x/otelx" @@ -25,6 +27,8 @@ type ( sessionIssuerDependencies interface { session.ManagementProvider session.PersistenceProvider + sessiontokenexchange.PersistenceProvider + config.Provider x.WriterProvider } SessionIssuerProvider interface { @@ -52,6 +56,19 @@ func (e *SessionIssuer) executePostRegistrationPostPersistHook(w http.ResponseWr } if a.Type == flow.TypeAPI { + if ok, _ := e.r.SessionTokenExchangePersister().CodeExistsForFlow(r.Context(), a.ID); ok { + if err := e.r.SessionTokenExchangePersister().UpdateSessionOnExchanger(r.Context(), a.ID, s.ID); err != nil { + return errors.WithStack(err) + } + returnTo, err := x.SecureRedirectTo(r, e.r.Config().SelfServiceBrowserDefaultReturnTo(r.Context()), a.SecureRedirectToOpts(r.Context(), e.r)...) + if err != nil { + return errors.WithStack(err) + } + + http.Redirect(w, r, returnTo.String(), http.StatusFound) + return nil + } + a.AddContinueWith(flow.NewContinueWithSetToken(s.Token)) e.r.Writer().Write(w, r, ®istration.APIFlowResponse{ Session: s, diff --git a/selfservice/sessiontokenexchange/handler.go b/selfservice/sessiontokenexchange/handler.go new file mode 100644 index 000000000000..3cebcaddcbaf --- /dev/null +++ b/selfservice/sessiontokenexchange/handler.go @@ -0,0 +1,126 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package sessiontokenexchange + +import ( + "net/http" + + "github.com/julienschmidt/httprouter" + + "github.com/ory/herodot" + "github.com/ory/kratos/driver/config" + "github.com/ory/kratos/session" + "github.com/ory/kratos/x" +) + +const ( + RouteExchangeCodeForSessionToken = "/self-service/exchange-code-for-session-token" // #nosec G101 +) + +type ( + handlerDependencies interface { + PersistenceProvider + config.Provider + x.WriterProvider + session.PersistenceProvider + } + + HandlerProvider interface { + SessionTokenExchangeHandler() *Handler + } + Handler struct { + d handlerDependencies + } +) + +func NewHandler(d handlerDependencies) *Handler { + return &Handler{d: d} +} + +func (h *Handler) RegisterPublicRoutes(public *x.RouterPublic) { + public.GET(RouteExchangeCodeForSessionToken, h.exchangeCode) +} + +func (h *Handler) RegisterAdminRoutes(admin *x.RouterAdmin) { + admin.GET(RouteExchangeCodeForSessionToken, x.RedirectToPublicRoute(h.d)) +} + +// Exchange Session Token Parameters +// +// swagger:parameters exchangeSessionToken +// +//nolint:deadcode,unused +//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions +type exchangeSessionToken struct { + // The Session Token Exchange Code + // + // required: true + // in: query + SessionTokenExchangeCode string `json:"code"` +} + +// The Response for Registration Flows via API +// +// swagger:model successfulCodeExchangeResponse +type codeExchangeResponse struct { + // The Session Token + // + // A session token is equivalent to a session cookie, but it can be sent in the HTTP Authorization + // Header: + // + // Authorization: bearer ${session-token} + // + // The session token is only issued for API flows, not for Browser flows! + Token string `json:"session_token,omitempty"` + + // The Session + // + // The session contains information about the user, the session device, and so on. + // This is only available for API flows, not for Browser flows! + // + // required: true + Session *session.Session `json:"session"` +} + +// swagger:route GET /self-service/exchange-code-for-session-token frontend exchangeSessionToken +// +// # Exchange Session Token +// +// Produces: +// - application/json +// +// Schemes: http, https +// +// Responses: +// 200: successfulNativeLogin +// 403: errorGeneric +// 404: errorGeneric +// 410: errorGeneric +// default: errorGeneric +func (h *Handler) exchangeCode(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + code := r.URL.Query().Get("code") + ctx := r.Context() + + if code == "" { + h.d.Writer().WriteError(w, r, herodot.ErrBadRequest.WithReason(`"code" query param must be set`)) + return + } + + e, err := h.d.SessionTokenExchangePersister().GetExchangerFromCode(ctx, code) + if err != nil { + h.d.Writer().WriteError(w, r, herodot.ErrNotFound.WithReason(`no session yet for this "code"`)) + return + } + + sess, err := h.d.SessionPersister().GetSession(ctx, e.SessionID.UUID, session.ExpandDefault) + if err != nil { + h.d.Writer().WriteError(w, r, err) + return + } + + h.d.Writer().Write(w, r, &codeExchangeResponse{ + Token: sess.Token, + Session: sess, + }) +} diff --git a/selfservice/sessiontokenexchange/persistence.go b/selfservice/sessiontokenexchange/persistence.go new file mode 100644 index 000000000000..be0aec90efda --- /dev/null +++ b/selfservice/sessiontokenexchange/persistence.go @@ -0,0 +1,43 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package sessiontokenexchange + +import ( + "context" + "time" + + "github.com/gofrs/uuid" +) + +type Exchanger struct { + ID uuid.UUID `db:"id"` + NID uuid.UUID `db:"nid"` + FlowID uuid.UUID `db:"flow_id"` + SessionID uuid.NullUUID `db:"session_id"` + Code string `db:"code"` + + // CreatedAt is a helper struct field for gobuffalo.pop. + CreatedAt time.Time `db:"created_at"` + + // UpdatedAt is a helper struct field for gobuffalo.pop. + UpdatedAt time.Time `db:"updated_at"` +} + +func (e *Exchanger) TableName() string { + return "session_token_exchanges" +} + +type ( + Persister interface { + CreateSessionTokenExchanger(ctx context.Context, flowID uuid.UUID, code string) error + GetExchangerFromCode(ctx context.Context, code string) (*Exchanger, error) + UpdateSessionOnExchanger(ctx context.Context, flowID uuid.UUID, sessionID uuid.UUID) error + CodeExistsForFlow(ctx context.Context, flowID uuid.UUID) (bool, error) + MoveToNewFlow(ctx context.Context, oldFlow, newFlow uuid.UUID) error + } + + PersistenceProvider interface { + SessionTokenExchangePersister() Persister + } +) diff --git a/selfservice/sessiontokenexchange/test/persistence.go b/selfservice/sessiontokenexchange/test/persistence.go new file mode 100644 index 000000000000..9e1ce262f8a2 --- /dev/null +++ b/selfservice/sessiontokenexchange/test/persistence.go @@ -0,0 +1,143 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package test + +import ( + "context" + "testing" + + "github.com/gofrs/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ory/kratos/driver/config" + "github.com/ory/kratos/internal/testhelpers" + "github.com/ory/kratos/persistence" + "github.com/ory/x/randx" +) + +type testParams struct { + flowID, sessionID uuid.UUID + code string +} + +func newParams() testParams { + return testParams{ + flowID: uuid.Must(uuid.NewV4()), + sessionID: uuid.Must(uuid.NewV4()), + code: randx.MustString(64, randx.AlphaNum), + } +} + +func TestPersister(ctx context.Context, _ *config.Config, p interface { + persistence.Persister +}) func(t *testing.T) { + return func(t *testing.T) { + t.Parallel() + nid, p := testhelpers.NewNetworkUnlessExisting(t, ctx, p) + + t.Run("suite=create-update-get", func(t *testing.T) { + t.Parallel() + params := newParams() + + t.Run("step=create", func(t *testing.T) { + require.NoError(t, p.CreateSessionTokenExchanger(ctx, params.flowID, params.code)) + ok, err := p.CodeExistsForFlow(ctx, params.flowID) + assert.True(t, ok) + assert.NoError(t, err) + }) + t.Run("step=update", func(t *testing.T) { + require.NoError(t, p.UpdateSessionOnExchanger(ctx, params.flowID, params.sessionID)) + }) + t.Run("step=get", func(t *testing.T) { + e, err := p.GetExchangerFromCode(ctx, params.code) + require.NoError(t, err) + + assert.Equal(t, params.sessionID, e.SessionID.UUID) + assert.Equal(t, nid, e.NID) + }) + }) + + t.Run("suite=CodeExistsForFlow", func(t *testing.T) { + t.Parallel() + + t.Run("case=returns false for non-existing flow", func(t *testing.T) { + t.Parallel() + ok, err := p.CodeExistsForFlow(ctx, uuid.Must(uuid.NewV4())) + assert.False(t, ok) + assert.NoError(t, err) + }) + }) + + t.Run("suite=MoveToNewFlow", func(t *testing.T) { + t.Parallel() + + t.Run("case=move to new flow", func(t *testing.T) { + params := newParams() + other := newParams() + + require.NoError(t, p.CreateSessionTokenExchanger(ctx, params.flowID, params.code)) + require.NoError(t, p.MoveToNewFlow(ctx, params.flowID, other.flowID)) + require.NoError(t, p.UpdateSessionOnExchanger(ctx, other.flowID, params.sessionID)) + + e, err := p.GetExchangerFromCode(ctx, params.code) + require.NoError(t, err) + assert.Equal(t, params.sessionID, e.SessionID.UUID) + }) + }) + + t.Run("suite=GetExchangerFromCode", func(t *testing.T) { + t.Parallel() + + t.Run("case=errors if session not found", func(t *testing.T) { + t.Parallel() + params := newParams() + + require.NoError(t, p.CreateSessionTokenExchanger(ctx, params.flowID, params.code)) + e, err := p.GetExchangerFromCode(ctx, params.code) + + assert.Error(t, err) + assert.Nil(t, e) + }) + + t.Run("case=errors if code is invalid", func(t *testing.T) { + t.Parallel() + params := newParams() + other := newParams() + + require.NoError(t, p.CreateSessionTokenExchanger(ctx, params.flowID, params.code)) + require.NoError(t, p.UpdateSessionOnExchanger(ctx, params.flowID, params.sessionID)) + e, err := p.GetExchangerFromCode(ctx, other.code) + + assert.Error(t, err) + assert.Nil(t, e) + }) + + t.Run("case=errors if code is empty", func(t *testing.T) { + t.Parallel() + params := newParams() + + require.NoError(t, p.CreateSessionTokenExchanger(ctx, params.flowID, "")) + require.NoError(t, p.UpdateSessionOnExchanger(ctx, params.flowID, params.sessionID)) + e, err := p.GetExchangerFromCode(ctx, "") + + assert.Error(t, err) + assert.Nil(t, e) + }) + + t.Run("case=errors if other network ID", func(t *testing.T) { + t.Parallel() + params := newParams() + otherNID := uuid.Must(uuid.NewV4()) + + require.NoError(t, p.CreateSessionTokenExchanger(ctx, params.flowID, params.code)) + require.NoError(t, p.UpdateSessionOnExchanger(ctx, params.flowID, params.sessionID)) + e, err := p.WithNetworkID(otherNID).GetExchangerFromCode(ctx, params.code) + + assert.Error(t, err) + assert.Nil(t, e) + }) + }) + } +} diff --git a/selfservice/strategy/oidc/error.go b/selfservice/strategy/oidc/error.go index d901d474194d..d0d4b14ab1fc 100644 --- a/selfservice/strategy/oidc/error.go +++ b/selfservice/strategy/oidc/error.go @@ -21,9 +21,6 @@ var ( ErrIDTokenMissing = herodot.ErrBadRequest. WithError("authentication failed because id_token is missing"). WithReasonf(`Authentication failed because no id_token was returned. Please accept the "openid" permission and try again.`) - - ErrAPIFlowNotSupported = herodot.ErrBadRequest.WithError("API-based flows are not supported for this method"). - WithReasonf("Social Sign In and OpenID Connect are only supported for flows initiated using the Browser endpoint.") ) func logUpstreamError(l *logrusx.Logger, resp *http.Response) error { diff --git a/selfservice/strategy/oidc/strategy.go b/selfservice/strategy/oidc/strategy.go index b7be44f03f55..268d540f5970 100644 --- a/selfservice/strategy/oidc/strategy.go +++ b/selfservice/strategy/oidc/strategy.go @@ -6,14 +6,13 @@ package oidc import ( "bytes" "context" - "encoding/base64" "encoding/json" - "fmt" "net/http" "path/filepath" "strings" "github.com/ory/kratos/cipher" + "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/x/jsonnetsecure" "github.com/ory/kratos/text" @@ -76,6 +75,7 @@ type dependencies interface { session.ManagementProvider session.HandlerProvider + sessiontokenexchange.PersistenceProvider login.HookExecutorProvider login.FlowPersistenceProvider @@ -125,8 +125,7 @@ type authCodeContainer struct { } func generateState(flowID string) string { - state := x.NewUUID().String() - return base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", flowID, state))) + return flowID } func (s *Strategy) CountActiveFirstFactorCredentials(cc map[identity.CredentialsType]identity.Credentials) (count int, err error) { @@ -215,10 +214,6 @@ func (s *Strategy) validateFlow(ctx context.Context, r *http.Request, rid uuid.U } if ar, err := s.d.RegistrationFlowPersister().GetRegistrationFlow(ctx, rid); err == nil { - if ar.Type != flow.TypeBrowser { - return ar, ErrAPIFlowNotSupported - } - if err := ar.Valid(); err != nil { return ar, err } @@ -226,10 +221,6 @@ func (s *Strategy) validateFlow(ctx context.Context, r *http.Request, rid uuid.U } if ar, err := s.d.LoginFlowPersister().GetLoginFlow(ctx, rid); err == nil { - if ar.Type != flow.TypeBrowser { - return ar, ErrAPIFlowNotSupported - } - if err := ar.Valid(); err != nil { return ar, err } @@ -238,10 +229,6 @@ func (s *Strategy) validateFlow(ctx context.Context, r *http.Request, rid uuid.U ar, err := s.d.SettingsFlowPersister().GetSettingsFlow(ctx, rid) if err == nil { - if ar.Type != flow.TypeBrowser { - return ar, ErrAPIFlowNotSupported - } - sess, err := s.d.SessionManager().FetchFromRequest(ctx, r) if err != nil { return ar, err @@ -258,46 +245,74 @@ func (s *Strategy) validateFlow(ctx context.Context, r *http.Request, rid uuid.U func (s *Strategy) validateCallback(w http.ResponseWriter, r *http.Request) (flow.Flow, *authCodeContainer, error) { var ( - code = stringsx.Coalesce(r.URL.Query().Get("code"), r.URL.Query().Get("authCode")) - state = r.URL.Query().Get("state") + codeParam = stringsx.Coalesce(r.URL.Query().Get("code"), r.URL.Query().Get("authCode")) + stateParam = r.URL.Query().Get("state") + errorParam = r.URL.Query().Get("error") ) - if state == "" { + if stateParam == "" { return nil, nil, errors.WithStack(herodot.ErrBadRequest.WithReasonf(`Unable to complete OpenID Connect flow because the OpenID Provider did not return the state query parameter.`)) } - var cntnr authCodeContainer - if _, err := s.d.ContinuityManager().Continue(r.Context(), w, r, sessionName, continuity.WithPayload(&cntnr)); err != nil { + f, err := s.validateFlow(r.Context(), r, x.ParseUUID(stateParam)) + if err != nil { return nil, nil, err } - if state != cntnr.State { - return nil, &cntnr, errors.WithStack(herodot.ErrBadRequest.WithReasonf(`Unable to complete OpenID Connect flow because the query state parameter does not match the state parameter from the session cookie.`)) + cntnr := authCodeContainer{} + if f.GetType() == flow.TypeBrowser { + if _, err := s.d.ContinuityManager().Continue(r.Context(), w, r, sessionName, continuity.WithPayload(&cntnr)); err != nil { + return nil, nil, err + } + if stateParam != cntnr.State { + return nil, &cntnr, errors.WithStack(herodot.ErrBadRequest.WithReasonf(`Unable to complete OpenID Connect flow because the query state parameter does not match the state parameter from the session cookie.`)) + } + } else { + cntnr.State = stateParam + cntnr.FlowID = stateParam } - req, err := s.validateFlow(r.Context(), r, x.ParseUUID(cntnr.FlowID)) - if err != nil { - return nil, &cntnr, err + if errorParam != "" { + return f, &cntnr, errors.WithStack(herodot.ErrBadRequest.WithReasonf(`Unable to complete OpenID Connect flow because the OpenID Provider returned error "%s": %s`, r.URL.Query().Get("error"), r.URL.Query().Get("error_description"))) } - - if r.URL.Query().Get("error") != "" { - return req, &cntnr, errors.WithStack(herodot.ErrBadRequest.WithReasonf(`Unable to complete OpenID Connect flow because the OpenID Provider returned error "%s": %s`, r.URL.Query().Get("error"), r.URL.Query().Get("error_description"))) + if codeParam == "" { + return f, &cntnr, errors.WithStack(herodot.ErrBadRequest.WithReasonf(`Unable to complete OpenID Connect flow because the OpenID Provider did not return the code query parameter.`)) } - if code == "" { - return req, &cntnr, errors.WithStack(herodot.ErrBadRequest.WithReasonf(`Unable to complete OpenID Connect flow because the OpenID Provider did not return the code query parameter.`)) - } + return f, &cntnr, nil +} - return req, &cntnr, nil +func registrationOrLoginFlowID(flow any) (uuid.UUID, bool) { + switch f := flow.(type) { + case *registration.Flow: + return f.ID, true + case *login.Flow: + return f.ID, true + default: + return uuid.Nil, false + } } -func (s *Strategy) alreadyAuthenticated(w http.ResponseWriter, r *http.Request, req interface{}) bool { - // we assume an error means the user has no session - if _, err := s.d.SessionManager().FetchFromRequest(r.Context(), r); err == nil { - if _, ok := req.(*settings.Flow); ok { +func (s *Strategy) alreadyAuthenticated(w http.ResponseWriter, r *http.Request, f interface{}) bool { + ctx := r.Context() + + if sess, _ := s.d.SessionManager().FetchFromRequest(ctx, r); sess != nil { + if _, ok := f.(*settings.Flow); ok { // ignore this if it's a settings flow - } else if !isForced(req) { - http.Redirect(w, r, s.d.Config().SelfServiceBrowserDefaultReturnTo(r.Context()).String(), http.StatusSeeOther) + } else if !isForced(f) { + if flowID, ok := registrationOrLoginFlowID(f); ok { + if hasCode, _ := s.d.SessionTokenExchangePersister().CodeExistsForFlow(ctx, flowID); hasCode { + _ = s.d.SessionTokenExchangePersister().UpdateSessionOnExchanger(ctx, flowID, sess.ID) + } + } + returnTo := s.d.Config().SelfServiceBrowserDefaultReturnTo(ctx) + if redirecter, ok := f.(flow.FlowWithRedirect); ok { + r, err := x.SecureRedirectTo(r, returnTo, redirecter.SecureRedirectToOpts(ctx, s.d)...) + if err != nil { + returnTo = r + } + } + http.Redirect(w, r, returnTo.String(), http.StatusSeeOther) return true } } diff --git a/selfservice/strategy/oidc/strategy_login.go b/selfservice/strategy/oidc/strategy_login.go index ebf1b11cb23d..ee32e5a55a07 100644 --- a/selfservice/strategy/oidc/strategy_login.go +++ b/selfservice/strategy/oidc/strategy_login.go @@ -41,10 +41,6 @@ func (s *Strategy) RegisterLoginRoutes(r *x.RouterPublic) { } func (s *Strategy) PopulateLoginMethod(r *http.Request, requestedAAL identity.AuthenticatorAssuranceLevel, l *login.Flow) error { - if l.Type != flow.TypeBrowser { - return nil - } - // This strategy can only solve AAL1 if requestedAAL > identity.AuthenticatorAssuranceLevel1 { return nil @@ -109,7 +105,12 @@ func (s *Strategy) processLogin(w http.ResponseWriter, r *http.Request, a *login } // This flow only works for browsers anyways. - aa, err := s.d.RegistrationHandler().NewRegistrationFlow(w, r, flow.TypeBrowser, opts...) + aa, err := s.d.RegistrationHandler().NewRegistrationFlow(w, r, a.Type, opts...) + if err != nil { + return nil, s.handleError(w, r, a, provider.Config().ID, nil, err) + } + + err = s.d.SessionTokenExchangePersister().MoveToNewFlow(r.Context(), a.ID, aa.ID) if err != nil { return nil, s.handleError(w, r, a, provider.Config().ID, nil, err) } diff --git a/selfservice/strategy/oidc/strategy_registration.go b/selfservice/strategy/oidc/strategy_registration.go index d571cab7fd8f..3b879dc215d9 100644 --- a/selfservice/strategy/oidc/strategy_registration.go +++ b/selfservice/strategy/oidc/strategy_registration.go @@ -48,10 +48,6 @@ func (s *Strategy) RegisterRegistrationRoutes(r *x.RouterPublic) { } func (s *Strategy) PopulateRegistrationMethod(r *http.Request, f *registration.Flow) error { - if f.Type != flow.TypeBrowser { - return nil - } - return s.populateMethod(r, f.UI, text.NewInfoRegistrationWith) } @@ -196,9 +192,12 @@ func (s *Strategy) registrationToLogin(w http.ResponseWriter, r *http.Request, r opts = append(opts, login.WithFormErrorMessage(rf.UI.Messages)) } - // This endpoint only handles browser flow at the moment. - lf, _, err := s.d.LoginHandler().NewLoginFlow(w, r, flow.TypeBrowser, opts...) + lf, _, err := s.d.LoginHandler().NewLoginFlow(w, r, rf.Type, opts...) + if err != nil { + return nil, err + } + err = s.d.SessionTokenExchangePersister().MoveToNewFlow(r.Context(), rf.ID, lf.ID) if err != nil { return nil, err } diff --git a/spec/api.json b/spec/api.json index 94ecfead864e..ea3acb5b1535 100755 --- a/spec/api.json +++ b/spec/api.json @@ -1181,6 +1181,10 @@ "description": "ReturnTo contains the requested return_to URL.", "type": "string" }, + "session_token_exchange_code": { + "description": "SessionTokenExchangeCode holds the secret code that the client can use to retrieve a session token after the login flow has been completed.\nThis is only set if the client has requested a session token exchange code, and if the flow is of type \"api\",\nand only on creating the login flow.", + "type": "string" + }, "type": { "$ref": "#/components/schemas/selfServiceFlowType" }, @@ -1590,6 +1594,10 @@ "description": "ReturnTo contains the requested return_to URL.", "type": "string" }, + "session_token_exchange_code": { + "description": "SessionTokenExchangeCode holds the secret code that the client can use to retrieve a session token after the flow has been completed.\nThis is only set if the client has requested a session token exchange code, and if the flow is of type \"api\",\nand only on creating the flow.", + "type": "string" + }, "transient_payload": { "description": "TransientPayload is used to pass data from the registration to a webhook", "type": "object" @@ -1823,6 +1831,22 @@ "title": "State represents the state of this flow. It knows two states:", "type": "string" }, + "successfulCodeExchangeResponse": { + "description": "The Response for Registration Flows via API", + "properties": { + "session": { + "$ref": "#/components/schemas/session" + }, + "session_token": { + "description": "The Session Token\n\nA session token is equivalent to a session cookie, but it can be sent in the HTTP Authorization\nHeader:\n\nAuthorization: bearer ${session-token}\n\nThe session token is only issued for API flows, not for Browser flows!", + "type": "string" + } + }, + "required": [ + "session" + ], + "type": "object" + }, "successfulNativeLogin": { "description": "The Response for Login Flows via API", "properties": { @@ -4631,6 +4655,78 @@ ] } }, + "/self-service/exchange-code-for-session-token": { + "get": { + "operationId": "exchangeSessionToken", + "parameters": [ + { + "description": "The Session Token Exchange Code", + "in": "query", + "name": "code", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/successfulNativeLogin" + } + } + }, + "description": "successfulNativeLogin" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/errorGeneric" + } + } + }, + "description": "errorGeneric" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/errorGeneric" + } + } + }, + "description": "errorGeneric" + }, + "410": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/errorGeneric" + } + } + }, + "description": "errorGeneric" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/errorGeneric" + } + } + }, + "description": "errorGeneric" + } + }, + "summary": "Exchange Session Token", + "tags": [ + "frontend" + ] + } + }, "/self-service/login": { "post": { "description": ":::info\n\nThis endpoint is EXPERIMENTAL and subject to potential breaking changes in the future.\n\n:::\n\nUse this endpoint to complete a login flow. This endpoint\nbehaves differently for API and browser flows.\n\nAPI flows expect `application/json` to be sent in the body and responds with\nHTTP 200 and a application/json body with the session token on success;\nHTTP 410 if the original flow expired with the appropriate error messages set and optionally a `use_flow_id` parameter in the body;\nHTTP 400 on form validation errors.\n\nBrowser flows expect a Content-Type of `application/x-www-form-urlencoded` or `application/json` to be sent in the body and respond with\na HTTP 303 redirect to the post/after login URL or the `return_to` value if it was set and if the login succeeded;\na HTTP 303 redirect to the login UI URL with the flow ID containing the validation errors otherwise.\n\nBrowser flows with an accept header of `application/json` will not redirect but instead respond with\nHTTP 200 and a application/json body with the signed in identity and a `Set-Cookie` header on success;\nHTTP 303 redirect to a fresh login flow if the original flow expired with the appropriate error messages set;\nHTTP 400 on form validation errors.\n\nIf this endpoint is called with `Accept: application/json` in the header, the response contains the flow without a redirect. In the\ncase of an error, the `error.id` of the JSON response body can be one of:\n\n`session_already_available`: The user is already signed in.\n`security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred.\n`security_identity_mismatch`: The requested `?return_to` address is not allowed to be used. Adjust this in the configuration!\n`browser_location_change_required`: Usually sent when an AJAX request indicates that the browser needs to open a specific URL.\nMost likely used in Social Sign In flows.\n\nMore information can be found at [Ory Kratos User Login](https://www.ory.sh/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.sh/docs/kratos/self-service/flows/user-registration).", @@ -4767,6 +4863,22 @@ "schema": { "type": "string" } + }, + { + "description": "EnableSessionTokenExchangeCode requests the login flow to include a code that can be used to retrieve the session token\nafter the login flow has been completed.", + "in": "query", + "name": "enable_session_token_exchange_code", + "schema": { + "type": "boolean" + } + }, + { + "description": "The URL to return the browser to after the flow was completed.", + "in": "query", + "name": "return_to", + "schema": { + "type": "string" + } } ], "responses": { @@ -5499,6 +5611,24 @@ "get": { "description": "This endpoint initiates a registration flow for API clients such as mobile devices, smart TVs, and so on.\n\nIf a valid provided session cookie or session token is provided, a 400 Bad Request error\nwill be returned unless the URL query parameter `?refresh=true` is set.\n\nTo fetch an existing registration flow call `/self-service/registration/flows?flow=\u003cflow_id\u003e`.\n\nYou MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server\nPages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make\nyou vulnerable to a variety of CSRF attacks.\n\nIn the case of an error, the `error.id` of the JSON response body can be one of:\n\n`session_already_available`: The user is already signed in.\n`security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred.\n\nThis endpoint MUST ONLY be used in scenarios such as native mobile apps (React Native, Objective C, Swift, Java, ...).\n\nMore information can be found at [Ory Kratos User Login](https://www.ory.sh/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.sh/docs/kratos/self-service/flows/user-registration).", "operationId": "createNativeRegistrationFlow", + "parameters": [ + { + "description": "EnableSessionTokenExchangeCode requests the login flow to include a code that can be used to retrieve the session token\nafter the login flow has been completed.", + "in": "query", + "name": "enable_session_token_exchange_code", + "schema": { + "type": "boolean" + } + }, + { + "description": "The URL to return the browser to after the flow was completed.", + "in": "query", + "name": "return_to", + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "content": { diff --git a/spec/swagger.json b/spec/swagger.json index bc4da4127e0d..866e4cf1f358 100755 --- a/spec/swagger.json +++ b/spec/swagger.json @@ -1354,6 +1354,63 @@ } } }, + "/self-service/exchange-code-for-session-token": { + "get": { + "produces": [ + "application/json" + ], + "schemes": [ + "http", + "https" + ], + "tags": [ + "frontend" + ], + "summary": "Exchange Session Token", + "operationId": "exchangeSessionToken", + "parameters": [ + { + "type": "string", + "description": "The Session Token Exchange Code", + "name": "code", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "successfulNativeLogin", + "schema": { + "$ref": "#/definitions/successfulNativeLogin" + } + }, + "403": { + "description": "errorGeneric", + "schema": { + "$ref": "#/definitions/errorGeneric" + } + }, + "404": { + "description": "errorGeneric", + "schema": { + "$ref": "#/definitions/errorGeneric" + } + }, + "410": { + "description": "errorGeneric", + "schema": { + "$ref": "#/definitions/errorGeneric" + } + }, + "default": { + "description": "errorGeneric", + "schema": { + "$ref": "#/definitions/errorGeneric" + } + } + } + } + }, "/self-service/login": { "post": { "description": ":::info\n\nThis endpoint is EXPERIMENTAL and subject to potential breaking changes in the future.\n\n:::\n\nUse this endpoint to complete a login flow. This endpoint\nbehaves differently for API and browser flows.\n\nAPI flows expect `application/json` to be sent in the body and responds with\nHTTP 200 and a application/json body with the session token on success;\nHTTP 410 if the original flow expired with the appropriate error messages set and optionally a `use_flow_id` parameter in the body;\nHTTP 400 on form validation errors.\n\nBrowser flows expect a Content-Type of `application/x-www-form-urlencoded` or `application/json` to be sent in the body and respond with\na HTTP 303 redirect to the post/after login URL or the `return_to` value if it was set and if the login succeeded;\na HTTP 303 redirect to the login UI URL with the flow ID containing the validation errors otherwise.\n\nBrowser flows with an accept header of `application/json` will not redirect but instead respond with\nHTTP 200 and a application/json body with the signed in identity and a `Set-Cookie` header on success;\nHTTP 303 redirect to a fresh login flow if the original flow expired with the appropriate error messages set;\nHTTP 400 on form validation errors.\n\nIf this endpoint is called with `Accept: application/json` in the header, the response contains the flow without a redirect. In the\ncase of an error, the `error.id` of the JSON response body can be one of:\n\n`session_already_available`: The user is already signed in.\n`security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred.\n`security_identity_mismatch`: The requested `?return_to` address is not allowed to be used. Adjust this in the configuration!\n`browser_location_change_required`: Usually sent when an AJAX request indicates that the browser needs to open a specific URL.\nMost likely used in Social Sign In flows.\n\nMore information can be found at [Ory Kratos User Login](https://www.ory.sh/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.sh/docs/kratos/self-service/flows/user-registration).", @@ -1474,6 +1531,18 @@ "description": "The Session Token of the Identity performing the settings flow.", "name": "X-Session-Token", "in": "header" + }, + { + "type": "boolean", + "description": "EnableSessionTokenExchangeCode requests the login flow to include a code that can be used to retrieve the session token\nafter the login flow has been completed.", + "name": "enable_session_token_exchange_code", + "in": "query" + }, + { + "type": "string", + "description": "The URL to return the browser to after the flow was completed.", + "name": "return_to", + "in": "query" } ], "responses": { @@ -2084,6 +2153,20 @@ ], "summary": "Create Registration Flow for Native Apps", "operationId": "createNativeRegistrationFlow", + "parameters": [ + { + "type": "boolean", + "description": "EnableSessionTokenExchangeCode requests the login flow to include a code that can be used to retrieve the session token\nafter the login flow has been completed.", + "name": "enable_session_token_exchange_code", + "in": "query" + }, + { + "type": "string", + "description": "The URL to return the browser to after the flow was completed.", + "name": "return_to", + "in": "query" + } + ], "responses": { "200": { "description": "registrationFlow", @@ -4036,6 +4119,10 @@ "description": "ReturnTo contains the requested return_to URL.", "type": "string" }, + "session_token_exchange_code": { + "description": "SessionTokenExchangeCode holds the secret code that the client can use to retrieve a session token after the login flow has been completed.\nThis is only set if the client has requested a session token exchange code, and if the flow is of type \"api\",\nand only on creating the login flow.", + "type": "string" + }, "type": { "$ref": "#/definitions/selfServiceFlowType" }, @@ -4430,6 +4517,10 @@ "description": "ReturnTo contains the requested return_to URL.", "type": "string" }, + "session_token_exchange_code": { + "description": "SessionTokenExchangeCode holds the secret code that the client can use to retrieve a session token after the flow has been completed.\nThis is only set if the client has requested a session token exchange code, and if the flow is of type \"api\",\nand only on creating the flow.", + "type": "string" + }, "transient_payload": { "description": "TransientPayload is used to pass data from the registration to a webhook", "type": "object" @@ -4639,6 +4730,22 @@ "type": "string", "title": "State represents the state of this flow. It knows two states:" }, + "successfulCodeExchangeResponse": { + "description": "The Response for Registration Flows via API", + "type": "object", + "required": [ + "session" + ], + "properties": { + "session": { + "$ref": "#/definitions/session" + }, + "session_token": { + "description": "The Session Token\n\nA session token is equivalent to a session cookie, but it can be sent in the HTTP Authorization\nHeader:\n\nAuthorization: bearer ${session-token}\n\nThe session token is only issued for API flows, not for Browser flows!", + "type": "string" + } + } + }, "successfulNativeLogin": { "description": "The Response for Login Flows via API", "type": "object", diff --git a/test/e2e/.gitignore b/test/e2e/.gitignore new file mode 100644 index 000000000000..7741d0441c51 --- /dev/null +++ b/test/e2e/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +/test-results/ +/playwright-report/ +/playwright/.cache/ +/playwright/playwright.env +/playwright/kratos.config.json diff --git a/test/e2e/package-lock.json b/test/e2e/package-lock.json index 539c3af10899..0cd1fcf6a2a5 100644 --- a/test/e2e/package-lock.json +++ b/test/e2e/package-lock.json @@ -9,11 +9,13 @@ "version": "0.0.1", "devDependencies": { "@ory/kratos-client": "0.0.0-next.8d3b018594f7", + "@playwright/test": "^1.32.3", "@types/node": "^16.9.6", "@types/yamljs": "^0.2.31", "chrome-remote-interface": "0.31.2", "cypress": "^11.2.0", "dayjs": "^1.10.4", + "dotenv": "^16.0.3", "got": "^11.8.2", "json-schema-to-typescript": "^12.0.0", "otplib": "^12.0.1", @@ -165,6 +167,25 @@ "@otplib/plugin-thirty-two": "^12.0.1" } }, + "node_modules/@playwright/test": { + "version": "1.32.3", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.32.3.tgz", + "integrity": "sha512-BvWNvK0RfBriindxhLVabi8BRe3X0J9EVjKlcmhxjg4giWBD/xleLcg2dz7Tx0agu28rczjNIPQWznwzDwVsZQ==", + "dev": true, + "dependencies": { + "@types/node": "*", + "playwright-core": "1.32.3" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, "node_modules/@sideway/address": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.3.tgz", @@ -1002,6 +1023,15 @@ "node": ">=0.4.0" } }, + "node_modules/dotenv": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", + "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", @@ -1285,6 +1315,20 @@ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "dev": true }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/get-stdin": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz", @@ -2148,6 +2192,18 @@ "node": ">=0.10.0" } }, + "node_modules/playwright-core": { + "version": "1.32.3", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.32.3.tgz", + "integrity": "sha512-SB+cdrnu74ZIn5Ogh/8278ngEh9NEEV0vR4sJFmK04h2iZpybfbqBY0bX6+BLYWVdV12JLLI+JEFtSnYgR+mWg==", + "dev": true, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/prettier": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.5.tgz", @@ -2894,6 +2950,17 @@ "@otplib/plugin-thirty-two": "^12.0.1" } }, + "@playwright/test": { + "version": "1.32.3", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.32.3.tgz", + "integrity": "sha512-BvWNvK0RfBriindxhLVabi8BRe3X0J9EVjKlcmhxjg4giWBD/xleLcg2dz7Tx0agu28rczjNIPQWznwzDwVsZQ==", + "dev": true, + "requires": { + "@types/node": "*", + "fsevents": "2.3.2", + "playwright-core": "1.32.3" + } + }, "@sideway/address": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.3.tgz", @@ -3552,6 +3619,12 @@ "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", "dev": true }, + "dotenv": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", + "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==", + "dev": true + }, "ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", @@ -3778,6 +3851,13 @@ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "dev": true }, + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true + }, "get-stdin": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz", @@ -4428,6 +4508,12 @@ "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", "dev": true }, + "playwright-core": { + "version": "1.32.3", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.32.3.tgz", + "integrity": "sha512-SB+cdrnu74ZIn5Ogh/8278ngEh9NEEV0vR4sJFmK04h2iZpybfbqBY0bX6+BLYWVdV12JLLI+JEFtSnYgR+mWg==", + "dev": true + }, "prettier": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.5.tgz", diff --git a/test/e2e/package.json b/test/e2e/package.json index 07618eb6fe11..b55910680856 100644 --- a/test/e2e/package.json +++ b/test/e2e/package.json @@ -6,10 +6,13 @@ "test": "cypress run --browser chrome", "test:watch": "cypress open --browser chrome", "text-run": "exit 0", - "wait-on": "wait-on" + "wait-on": "wait-on", + "playwright": "playwright test", + "playwright:ui": "playwright test --ui" }, "devDependencies": { "@ory/kratos-client": "0.0.0-next.8d3b018594f7", + "@playwright/test": "^1.32.3", "@types/node": "^16.9.6", "@types/yamljs": "^0.2.31", "chrome-remote-interface": "0.31.2", @@ -20,6 +23,7 @@ "otplib": "^12.0.1", "typescript": "^4.7.4", "wait-on": "5.3.0", - "yamljs": "^0.3.0" + "yamljs": "^0.3.0", + "dotenv": "^16.0.3" } } diff --git a/test/e2e/playwright/setup/default_config.ts b/test/e2e/playwright/setup/default_config.ts new file mode 100644 index 000000000000..468613f4fd9d --- /dev/null +++ b/test/e2e/playwright/setup/default_config.ts @@ -0,0 +1,125 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { OryKratosConfiguration } from "../../cypress/support/config" + +export const default_config: OryKratosConfiguration = { + dsn: process.env["DSN"], + identity: { + schemas: [ + { + id: "default", + url: "file://test/e2e/profiles/oidc/identity.traits.schema.json", + }, + ], + }, + serve: { + public: { + base_url: "http://localhost:4455/", + cors: { + enabled: true, + allowed_origins: ["http://localhost:3000", "http://localhost:4457"], + allowed_headers: ["Authorization", "Content-Type", "X-Session-Token"], + }, + }, + }, + + log: { + level: "trace", + leak_sensitive_values: true, + }, + secrets: { + cookie: ["PLEASE-CHANGE-ME-I-AM-VERY-INSECURE"], + cipher: ["secret-thirty-two-character-long"], + }, + selfservice: { + default_browser_return_url: "http://localhost:4455/", + allowed_return_urls: [ + "http://localhost:4455", + "http://localhost:4457", + "https://www.ory.sh/", + "https://example.org/", + "https://www.example.org/", + "exp://example.com/my-app", + "https://example.com/my-app", + ], + methods: { + link: { + config: { + lifespan: "1h", + }, + }, + code: { + config: { + lifespan: "1h", + }, + }, + oidc: { + enabled: true, + config: { + providers: [ + { + id: "hydra", + label: "Ory", + provider: "generic", + client_id: process.env["OIDC_HYDRA_CLIENT_ID"], + client_secret: process.env["OIDC_HYDRA_CLIENT_SECRET"], + issuer_url: "http://localhost:4444/", + scope: ["offline"], + mapper_url: "file://test/e2e/profiles/oidc/hydra.jsonnet", + }, + ], + }, + }, + }, + + flows: { + settings: { + privileged_session_max_age: "5m", + ui_url: "http://localhost:4455/settings", + }, + logout: { + after: { + default_browser_return_url: "http://localhost:4455/login", + }, + }, + registration: { + ui_url: "http://localhost:4455/registration", + after: { + password: { + hooks: [ + { + hook: "session", + }, + ], + }, + oidc: { + hooks: [ + { + hook: "session", + }, + ], + }, + }, + }, + login: { + ui_url: "http://localhost:4455/login", + }, + error: { + ui_url: "http://localhost:4455/error", + }, + verification: { + ui_url: "http://localhost:4455/verify", + }, + recovery: { + ui_url: "http://localhost:4455/recovery", + }, + }, + }, + + courier: { + smtp: { + connection_uri: "smtps://test:test@localhost:1025/?skip_ssl_verify=true", + }, + }, +} diff --git a/test/e2e/playwright/setup/global_setup.ts b/test/e2e/playwright/setup/global_setup.ts new file mode 100644 index 000000000000..92a42432fc96 --- /dev/null +++ b/test/e2e/playwright/setup/global_setup.ts @@ -0,0 +1,12 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import fs from "fs" +import { default_config } from "./default_config" + +export default async function globalSetup() { + await fs.promises.writeFile( + "playwright/kratos.config.json", + JSON.stringify(default_config), + ) +} diff --git a/test/e2e/playwright/tests/app_login.spec.ts b/test/e2e/playwright/tests/app_login.spec.ts new file mode 100644 index 000000000000..d27d872f3d42 --- /dev/null +++ b/test/e2e/playwright/tests/app_login.spec.ts @@ -0,0 +1,118 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect, Page } from "@playwright/test" + +test.describe.configure({ mode: "parallel" }) + +async function performOidcLogin(popup: Page, username: string) { + await popup.waitForLoadState() + + await popup.locator("#username").fill(username) + await popup.getByRole("button", { name: "login" }).click() + + await popup.locator("#offline").click() + await popup.locator("#openid").click() + await popup.locator("#website").fill("https://example.com") + await popup.getByRole("button", { name: "login" }).click() +} + +async function rejectOidcLogin(popup: Page) { + await popup.waitForLoadState() + await popup.getByRole("button", { name: "reject" }).click() + await popup.close() +} + +async function testRegistrationOrLogin(page: Page, username: string) { + const popupPromise = page.waitForEvent("popup") + await page.getByText(/sign (up|in) with ory/i).click() + const popup = await popupPromise + + await performOidcLogin(popup, "registration@example.com") + + await page.waitForURL("Home") + expect(popup.isClosed()).toBeTruthy() + await expect(page.getByText("Welcome back")).toBeVisible() +} + +async function logout(page: Page) { + return page.getByTestId("logout").click() +} + +async function testRegistration(page: Page, username: string) { + await page.goto("Registration") + return testRegistrationOrLogin(page, username) +} + +async function testLogin(page: Page, username: string) { + await page.goto("Login") + return testRegistrationOrLogin(page, username) +} + +test.describe("Registration", () => { + test("register twice", async ({ page }) => { + await testRegistration(page, "registration@example.com") + await logout(page) + await testRegistration(page, "registration@example.com") + }) + + test("register, then login", async ({ page }) => { + await testRegistration(page, "registration-login@example.com") + await logout(page) + await testLogin(page, "registration-login@example.com") + }) + + test("register, cancel, register", async ({ page }) => { + await page.goto("Registration") + + let popupPromise = page.waitForEvent("popup") + await page.getByText(/sign (up|in) with ory/i).click() + let popup = await popupPromise + + await rejectOidcLogin(popup) + + await expect(page.getByText("login rejected request")).toBeVisible() + popupPromise = page.waitForEvent("popup") + await page.getByText(/continue/i).click() + popup = await popupPromise + await performOidcLogin(popup, "register-reject-then-accept@example.com") + + await page.waitForURL("Home") + expect(popup.isClosed()).toBeTruthy() + await expect(page.getByText("Welcome back")).toBeVisible() + }) +}) + +test.describe("Login", () => { + test("login twice", async ({ page }) => { + await testLogin(page, "login@example.com") + await logout(page) + await testLogin(page, "login@example.com") + }) + + test("register, then login", async ({ page }) => { + await testLogin(page, "login-registration@example.com") + await logout(page) + await testRegistration(page, "login-registration@example.com") + }) + + test("login, cancel, login", async ({ page }) => { + await page.goto("Login") + + let popupPromise = page.waitForEvent("popup") + await page.getByText(/sign (up|in) with ory/i).click() + let popup = await popupPromise + + await rejectOidcLogin(popup) + + await expect(page.getByText("login rejected request")).toBeVisible() + popupPromise = page.waitForEvent("popup") + await page.getByText(/sign in with ory/i).click() + popup = await popupPromise + await performOidcLogin(popup, "login-reject-then-accept@example.com") + + await page.waitForURL("Home") + expect(popup.isClosed()).toBeTruthy() + await expect(page.getByText("Welcome back")).toBeVisible() + }) +}) diff --git a/test/e2e/run.sh b/test/e2e/run.sh index ac7b03bf6d40..6590da1fd97f 100755 --- a/test/e2e/run.sh +++ b/test/e2e/run.sh @@ -252,6 +252,14 @@ prepare() { PORT=4455 npm run start \ >"${base}/test/e2e/proxy.e2e.log" 2>&1 & ) + + # Make the environment available to Playwright + env | grep KRATOS_ > test/e2e/playwright/playwright.env + env | grep TEST_DATABASE_ >> test/e2e/playwright/playwright.env + env | grep OIDC_ >> test/e2e/playwright/playwright.env + env | grep CYPRESS_ >> test/e2e/playwright/playwright.env + echo LOG_LEAK_SENSITIVE_VALUES=true >> test/e2e/playwright/playwright.env + echo DEV_DISABLE_API_FLOW_ENFORCEMENT=true >> test/e2e/playwright/playwright.env } run() { diff --git a/x/redir_test.go b/x/redir_test.go index 02e82c2fdd03..bbb8417f8b91 100644 --- a/x/redir_test.go +++ b/x/redir_test.go @@ -38,13 +38,13 @@ func TestRedirectToPublicAdminRoute(t *testing.T) { pub.POST("/admin/privileged", x.RedirectToAdminRoute(reg)) adm.POST("/privileged", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { body, _ := io.ReadAll(r.Body) - w.Write(body) + _, _ = w.Write(body) }) adm.POST("/read", x.RedirectToPublicRoute(reg)) pub.POST("/read", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { body, _ := io.ReadAll(r.Body) - w.Write(body) + _, _ = w.Write(body) }) for k, tc := range []struct { diff --git a/x/xsql/sql.go b/x/xsql/sql.go index 7c892d24df06..f2354d082ca1 100644 --- a/x/xsql/sql.go +++ b/x/xsql/sql.go @@ -10,6 +10,7 @@ import ( "github.com/gobuffalo/pop/v6" "github.com/ory/kratos/selfservice/errorx" + "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/kratos/continuity" "github.com/ory/kratos/courier" @@ -54,6 +55,7 @@ func CleanSQL(t testing.TB, c *pop.Connection) { new(identity.RecoveryAddress).TableName(ctx), new(identity.Identity).TableName(ctx), new(identity.CredentialsTypeTable).TableName(ctx), + new(sessiontokenexchange.Exchanger).TableName(), "networks", "schema_migration", } { From b4853a6a2c54fb30d1f6ea20b4f068c633ab8c31 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Wed, 19 Apr 2023 09:10:53 +0200 Subject: [PATCH 02/15] fixup: address review comments, fix tests --- internal/client-go/api_frontend.go | 4 ++-- internal/httpclient/api_frontend.go | 4 ++-- selfservice/flow/login/handler.go | 4 ++-- selfservice/flow/login/handler_test.go | 4 ++-- selfservice/flow/registration/handler.go | 4 ++-- selfservice/flow/registration/handler_test.go | 4 ++-- test/e2e/cypress.config.ts | 2 +- test/e2e/playwright/tests/app_login.spec.ts | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/internal/client-go/api_frontend.go b/internal/client-go/api_frontend.go index e4fe9177eeea..79d4193dc06e 100644 --- a/internal/client-go/api_frontend.go +++ b/internal/client-go/api_frontend.go @@ -1955,7 +1955,7 @@ func (a *FrontendApiService) CreateNativeLoginFlowExecute(r FrontendApiApiCreate localVarQueryParams.Add("aal", parameterToString(*r.aal, "")) } if r.enableSessionTokenExchangeCode != nil { - localVarQueryParams.Add("enable_session_token_exchange_code", parameterToString(*r.enableSessionTokenExchangeCode, "")) + localVarQueryParams.Add("return_session_token_exchange_code", parameterToString(*r.enableSessionTokenExchangeCode, "")) } if r.returnTo != nil { localVarQueryParams.Add("return_to", parameterToString(*r.returnTo, "")) @@ -2241,7 +2241,7 @@ func (a *FrontendApiService) CreateNativeRegistrationFlowExecute(r FrontendApiAp localVarFormParams := url.Values{} if r.enableSessionTokenExchangeCode != nil { - localVarQueryParams.Add("enable_session_token_exchange_code", parameterToString(*r.enableSessionTokenExchangeCode, "")) + localVarQueryParams.Add("return_session_token_exchange_code", parameterToString(*r.enableSessionTokenExchangeCode, "")) } if r.returnTo != nil { localVarQueryParams.Add("return_to", parameterToString(*r.returnTo, "")) diff --git a/internal/httpclient/api_frontend.go b/internal/httpclient/api_frontend.go index e4fe9177eeea..79d4193dc06e 100644 --- a/internal/httpclient/api_frontend.go +++ b/internal/httpclient/api_frontend.go @@ -1955,7 +1955,7 @@ func (a *FrontendApiService) CreateNativeLoginFlowExecute(r FrontendApiApiCreate localVarQueryParams.Add("aal", parameterToString(*r.aal, "")) } if r.enableSessionTokenExchangeCode != nil { - localVarQueryParams.Add("enable_session_token_exchange_code", parameterToString(*r.enableSessionTokenExchangeCode, "")) + localVarQueryParams.Add("return_session_token_exchange_code", parameterToString(*r.enableSessionTokenExchangeCode, "")) } if r.returnTo != nil { localVarQueryParams.Add("return_to", parameterToString(*r.returnTo, "")) @@ -2241,7 +2241,7 @@ func (a *FrontendApiService) CreateNativeRegistrationFlowExecute(r FrontendApiAp localVarFormParams := url.Values{} if r.enableSessionTokenExchangeCode != nil { - localVarQueryParams.Add("enable_session_token_exchange_code", parameterToString(*r.enableSessionTokenExchangeCode, "")) + localVarQueryParams.Add("return_session_token_exchange_code", parameterToString(*r.enableSessionTokenExchangeCode, "")) } if r.returnTo != nil { localVarQueryParams.Add("return_to", parameterToString(*r.returnTo, "")) diff --git a/selfservice/flow/login/handler.go b/selfservice/flow/login/handler.go index f4ed0d8b56e1..515944177370 100644 --- a/selfservice/flow/login/handler.go +++ b/selfservice/flow/login/handler.go @@ -136,7 +136,7 @@ func (h *Handler) NewLoginFlow(w http.ResponseWriter, r *http.Request, ft flow.T return nil, nil, errors.WithStack(herodot.ErrBadRequest.WithReasonf("Unable to parse AuthenticationMethod Assurance Level (AAL): %s", cs.ToUnknownCaseErr())) } - if ft == flow.TypeAPI && r.URL.Query().Get("enable_session_token_exchange_code") == "true" { + if ft == flow.TypeAPI && r.URL.Query().Get("return_session_token_exchange_code") == "true" { // Panicing here is ok since it will return a 500 to the user, which is accurate for when // we can't generate a random string. f.SessionTokenExchangeCode = randx.MustString(64, randx.AlphaNum) @@ -269,7 +269,7 @@ type createNativeLoginFlow struct { // after the login flow has been completed. // // in: query - EnableSessionTokenExchangeCode bool `json:"enable_session_token_exchange_code"` + EnableSessionTokenExchangeCode bool `json:"return_session_token_exchange_code"` // The URL to return the browser to after the flow was completed. // diff --git a/selfservice/flow/login/handler_test.go b/selfservice/flow/login/handler_test.go index e2eee8623851..fdf3d951871f 100644 --- a/selfservice/flow/login/handler_test.go +++ b/selfservice/flow/login/handler_test.go @@ -396,7 +396,7 @@ func TestFlowLifecycle(t *testing.T) { }) t.Run("case=returns session exchange code", func(t *testing.T) { - res, body := initFlow(t, urlx.ParseOrPanic("/?enable_session_token_exchange_code=true").Query(), true) + res, body := initFlow(t, urlx.ParseOrPanic("/?return_session_token_exchange_code=true").Query(), true) assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow) assert.NotEmpty(t, gjson.GetBytes(body, "session_token_exchange_code").String()) }) @@ -478,7 +478,7 @@ func TestFlowLifecycle(t *testing.T) { }) t.Run("case=never returns a session token exchange code", func(t *testing.T) { - _, body := initFlow(t, urlx.ParseOrPanic("/?enable_session_token_exchange_code=true").Query(), false) + _, body := initFlow(t, urlx.ParseOrPanic("/?return_session_token_exchange_code=true").Query(), false) assertion(body, false, false) assert.Empty(t, gjson.GetBytes(body, "session_token_exchange_code").String()) }) diff --git a/selfservice/flow/registration/handler.go b/selfservice/flow/registration/handler.go index a14d60f1822e..0f4ee24d8189 100644 --- a/selfservice/flow/registration/handler.go +++ b/selfservice/flow/registration/handler.go @@ -123,7 +123,7 @@ func (h *Handler) NewRegistrationFlow(w http.ResponseWriter, r *http.Request, ft o(f) } - if ft == flow.TypeAPI && r.URL.Query().Get("enable_session_token_exchange_code") == "true" { + if ft == flow.TypeAPI && r.URL.Query().Get("return_session_token_exchange_code") == "true" { // Panicing here is ok since it will return a 500 to the user, which is accurate for when // we can't generate a random string. f.SessionTokenExchangeCode = randx.MustString(64, randx.AlphaNum) @@ -221,7 +221,7 @@ type createNativeRegistrationFlow struct { // after the login flow has been completed. // // in: query - EnableSessionTokenExchangeCode bool `json:"enable_session_token_exchange_code"` + EnableSessionTokenExchangeCode bool `json:"return_session_token_exchange_code"` // The URL to return the browser to after the flow was completed. // diff --git a/selfservice/flow/registration/handler_test.go b/selfservice/flow/registration/handler_test.go index 15f5e883a77a..1ed093609400 100644 --- a/selfservice/flow/registration/handler_test.go +++ b/selfservice/flow/registration/handler_test.go @@ -146,7 +146,7 @@ func TestInitFlow(t *testing.T) { }) t.Run("case=returns a session token exchange code", func(t *testing.T) { - res, body := initFlow(t, urlx.ParseOrPanic("/?enable_session_token_exchange_code=true").Query(), true) + res, body := initFlow(t, urlx.ParseOrPanic("/?return_session_token_exchange_code=true").Query(), true) assert.Contains(t, res.Request.URL.String(), registration.RouteInitAPIFlow) assertion(body, false, true) assert.NotEmpty(t, gjson.GetBytes(body, "session_token_exchange_code").String()) @@ -168,7 +168,7 @@ func TestInitFlow(t *testing.T) { }) t.Run("case=never returns a session token exchange code", func(t *testing.T) { - _, body := initFlow(t, urlx.ParseOrPanic("/?enable_session_token_exchange_code=true").Query(), false) + _, body := initFlow(t, urlx.ParseOrPanic("/?return_session_token_exchange_code=true").Query(), false) assertion(body, false, false) assert.Empty(t, gjson.GetBytes(body, "session_token_exchange_code").String()) }) diff --git a/test/e2e/cypress.config.ts b/test/e2e/cypress.config.ts index 0acc7493a4d7..b2ff781b71bf 100644 --- a/test/e2e/cypress.config.ts +++ b/test/e2e/cypress.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ }, videosFolder: "cypress/videos", screenshotsFolder: "cypress/screenshots", - excludeSpecPattern: "**/*snapshots.js", + excludeSpecPattern: ["**/*snapshots.js", "playwright/**"], supportFile: "cypress/support/index.js", specPattern: "**/*.spec.{js,ts}", baseUrl: "http://localhost:4455/", diff --git a/test/e2e/playwright/tests/app_login.spec.ts b/test/e2e/playwright/tests/app_login.spec.ts index d27d872f3d42..abe7ead3115f 100644 --- a/test/e2e/playwright/tests/app_login.spec.ts +++ b/test/e2e/playwright/tests/app_login.spec.ts @@ -90,7 +90,7 @@ test.describe("Login", () => { await testLogin(page, "login@example.com") }) - test("register, then login", async ({ page }) => { + test("login, then register", async ({ page }) => { await testLogin(page, "login-registration@example.com") await logout(page) await testRegistration(page, "login-registration@example.com") From db6e38583854b56905c51a9d57d0b4b99195b269 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Wed, 19 Apr 2023 09:27:33 +0200 Subject: [PATCH 03/15] fix indices --- ...0000000001_create_session_token_exchanges.mysql.up.sql | 8 ++++++-- ...0405000000000001_create_session_token_exchanges.up.sql | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.mysql.up.sql b/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.mysql.up.sql index 01e3f6f0a6de..31573333b47a 100644 --- a/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.mysql.up.sql +++ b/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.mysql.up.sql @@ -11,5 +11,9 @@ CREATE TABLE session_token_exchanges ( -- Relevant query: -- SELECT * from session_token_exchanges --- WHERE flow_id = ? AND nid = ? AND code = ? AND session_id IS NOT NULL AND code <> ''; -CREATE INDEX session_token_exchanges_nid_flow_id_code_idx ON session_token_exchanges (nid, flow_id, code); +-- WHERE nid = ? AND code = ? AND code <> '' AND session_id IS NOT NULL +CREATE INDEX session_token_exchanges_nid_code_idx ON session_token_exchanges (code, nid); + +-- Relevant query: +-- UPDATE session_token_exchanges SET session_id = ? WHERE flow_id = ? AND nid = ? +CREATE INDEX session_token_exchanges_nid_flow_id_idx ON session_token_exchanges (flow_id, nid); diff --git a/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.up.sql b/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.up.sql index 65f2b5b230e5..a1ab357955b7 100644 --- a/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.up.sql +++ b/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.up.sql @@ -11,5 +11,9 @@ CREATE TABLE session_token_exchanges ( -- Relevant query: -- SELECT * from session_token_exchanges --- WHERE flow_id = ? AND nid = ? AND code = ? AND session_id IS NOT NULL AND code <> ''; -CREATE INDEX session_token_exchanges_nid_flow_id_code_idx ON session_token_exchanges (nid, flow_id, code); +-- WHERE nid = ? AND code = ? AND code <> '' AND session_id IS NOT NULL +CREATE INDEX session_token_exchanges_nid_code_idx ON session_token_exchanges (code, nid); + +-- Relevant query: +-- UPDATE session_token_exchanges SET session_id = ? WHERE flow_id = ? AND nid = ? +CREATE INDEX session_token_exchanges_nid_flow_id_idx ON session_token_exchanges (flow_id, nid); From 737342a3f790177f5725e72d02be94fb3fcc0996 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Wed, 19 Apr 2023 09:55:35 +0200 Subject: [PATCH 04/15] add update limit clause --- .../sql/persister_sessiontokenexchanger.go | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/persistence/sql/persister_sessiontokenexchanger.go b/persistence/sql/persister_sessiontokenexchanger.go index a7a117385986..2509d60f1d2f 100644 --- a/persistence/sql/persister_sessiontokenexchanger.go +++ b/persistence/sql/persister_sessiontokenexchanger.go @@ -7,6 +7,7 @@ import ( "context" "fmt" + "github.com/gobuffalo/pop/v6" "github.com/gofrs/uuid" "github.com/pkg/errors" @@ -17,6 +18,16 @@ import ( var _ sessiontokenexchange.Persister = new(Persister) +func updateLimitClause(conn *pop.Connection) string { + // Not all databases support limiting in update clauses. + switch conn.Dialect.Name() { + case "sqlite3", "postgres": + return "" + default: + return "LIMIT 1" + } +} + func (p *Persister) CreateSessionTokenExchanger(ctx context.Context, flowID uuid.UUID, code string) (err error) { ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.CreateSessionTokenExchanger") defer otelx.End(span, &err) @@ -50,8 +61,9 @@ func (p *Persister) UpdateSessionOnExchanger(ctx context.Context, flowID uuid.UU defer otelx.End(span, &err) conn := p.GetConnection(ctx) - query := fmt.Sprintf("UPDATE %s SET session_id = ? WHERE flow_id = ? AND nid = ?", + query := fmt.Sprintf("UPDATE %s SET session_id = ? WHERE flow_id = ? AND nid = ? %s", conn.Dialect.Quote(new(sessiontokenexchange.Exchanger).TableName()), + updateLimitClause(conn), ) return sqlcon.HandleError(conn.RawQuery(query, sessionID, flowID, p.NetworkID(ctx)).Exec()) @@ -78,8 +90,9 @@ func (p *Persister) MoveToNewFlow(ctx context.Context, oldFlow, newFlow uuid.UUI defer otelx.End(span, &err) conn := p.GetConnection(ctx) - query := fmt.Sprintf("UPDATE %s SET flow_id = ? WHERE flow_id = ? AND nid = ?", + query := fmt.Sprintf("UPDATE %s SET flow_id = ? WHERE flow_id = ? AND nid = ? %s", conn.Dialect.Quote(new(sessiontokenexchange.Exchanger).TableName()), + updateLimitClause(conn), ) return sqlcon.HandleError(conn.RawQuery(query, newFlow, oldFlow, p.NetworkID(ctx)).Exec()) From e63bc32d8323b01675587070d9f9cb78f41e11b3 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Wed, 19 Apr 2023 10:33:44 +0200 Subject: [PATCH 05/15] make sdk --- internal/client-go/api_frontend.go | 20 ++++++++++---------- internal/httpclient/api_frontend.go | 20 ++++++++++---------- selfservice/strategy/oidc/strategy_login.go | 1 - spec/api.json | 4 ++-- spec/swagger.json | 4 ++-- 5 files changed, 24 insertions(+), 25 deletions(-) diff --git a/internal/client-go/api_frontend.go b/internal/client-go/api_frontend.go index 79d4193dc06e..1838d2473560 100644 --- a/internal/client-go/api_frontend.go +++ b/internal/client-go/api_frontend.go @@ -1862,7 +1862,7 @@ type FrontendApiApiCreateNativeLoginFlowRequest struct { refresh *bool aal *string xSessionToken *string - enableSessionTokenExchangeCode *bool + returnSessionTokenExchangeCode *bool returnTo *string } @@ -1878,8 +1878,8 @@ func (r FrontendApiApiCreateNativeLoginFlowRequest) XSessionToken(xSessionToken r.xSessionToken = &xSessionToken return r } -func (r FrontendApiApiCreateNativeLoginFlowRequest) EnableSessionTokenExchangeCode(enableSessionTokenExchangeCode bool) FrontendApiApiCreateNativeLoginFlowRequest { - r.enableSessionTokenExchangeCode = &enableSessionTokenExchangeCode +func (r FrontendApiApiCreateNativeLoginFlowRequest) ReturnSessionTokenExchangeCode(returnSessionTokenExchangeCode bool) FrontendApiApiCreateNativeLoginFlowRequest { + r.returnSessionTokenExchangeCode = &returnSessionTokenExchangeCode return r } func (r FrontendApiApiCreateNativeLoginFlowRequest) ReturnTo(returnTo string) FrontendApiApiCreateNativeLoginFlowRequest { @@ -1954,8 +1954,8 @@ func (a *FrontendApiService) CreateNativeLoginFlowExecute(r FrontendApiApiCreate if r.aal != nil { localVarQueryParams.Add("aal", parameterToString(*r.aal, "")) } - if r.enableSessionTokenExchangeCode != nil { - localVarQueryParams.Add("return_session_token_exchange_code", parameterToString(*r.enableSessionTokenExchangeCode, "")) + if r.returnSessionTokenExchangeCode != nil { + localVarQueryParams.Add("return_session_token_exchange_code", parameterToString(*r.returnSessionTokenExchangeCode, "")) } if r.returnTo != nil { localVarQueryParams.Add("return_to", parameterToString(*r.returnTo, "")) @@ -2167,12 +2167,12 @@ func (a *FrontendApiService) CreateNativeRecoveryFlowExecute(r FrontendApiApiCre type FrontendApiApiCreateNativeRegistrationFlowRequest struct { ctx context.Context ApiService FrontendApi - enableSessionTokenExchangeCode *bool + returnSessionTokenExchangeCode *bool returnTo *string } -func (r FrontendApiApiCreateNativeRegistrationFlowRequest) EnableSessionTokenExchangeCode(enableSessionTokenExchangeCode bool) FrontendApiApiCreateNativeRegistrationFlowRequest { - r.enableSessionTokenExchangeCode = &enableSessionTokenExchangeCode +func (r FrontendApiApiCreateNativeRegistrationFlowRequest) ReturnSessionTokenExchangeCode(returnSessionTokenExchangeCode bool) FrontendApiApiCreateNativeRegistrationFlowRequest { + r.returnSessionTokenExchangeCode = &returnSessionTokenExchangeCode return r } func (r FrontendApiApiCreateNativeRegistrationFlowRequest) ReturnTo(returnTo string) FrontendApiApiCreateNativeRegistrationFlowRequest { @@ -2240,8 +2240,8 @@ func (a *FrontendApiService) CreateNativeRegistrationFlowExecute(r FrontendApiAp localVarQueryParams := url.Values{} localVarFormParams := url.Values{} - if r.enableSessionTokenExchangeCode != nil { - localVarQueryParams.Add("return_session_token_exchange_code", parameterToString(*r.enableSessionTokenExchangeCode, "")) + if r.returnSessionTokenExchangeCode != nil { + localVarQueryParams.Add("return_session_token_exchange_code", parameterToString(*r.returnSessionTokenExchangeCode, "")) } if r.returnTo != nil { localVarQueryParams.Add("return_to", parameterToString(*r.returnTo, "")) diff --git a/internal/httpclient/api_frontend.go b/internal/httpclient/api_frontend.go index 79d4193dc06e..1838d2473560 100644 --- a/internal/httpclient/api_frontend.go +++ b/internal/httpclient/api_frontend.go @@ -1862,7 +1862,7 @@ type FrontendApiApiCreateNativeLoginFlowRequest struct { refresh *bool aal *string xSessionToken *string - enableSessionTokenExchangeCode *bool + returnSessionTokenExchangeCode *bool returnTo *string } @@ -1878,8 +1878,8 @@ func (r FrontendApiApiCreateNativeLoginFlowRequest) XSessionToken(xSessionToken r.xSessionToken = &xSessionToken return r } -func (r FrontendApiApiCreateNativeLoginFlowRequest) EnableSessionTokenExchangeCode(enableSessionTokenExchangeCode bool) FrontendApiApiCreateNativeLoginFlowRequest { - r.enableSessionTokenExchangeCode = &enableSessionTokenExchangeCode +func (r FrontendApiApiCreateNativeLoginFlowRequest) ReturnSessionTokenExchangeCode(returnSessionTokenExchangeCode bool) FrontendApiApiCreateNativeLoginFlowRequest { + r.returnSessionTokenExchangeCode = &returnSessionTokenExchangeCode return r } func (r FrontendApiApiCreateNativeLoginFlowRequest) ReturnTo(returnTo string) FrontendApiApiCreateNativeLoginFlowRequest { @@ -1954,8 +1954,8 @@ func (a *FrontendApiService) CreateNativeLoginFlowExecute(r FrontendApiApiCreate if r.aal != nil { localVarQueryParams.Add("aal", parameterToString(*r.aal, "")) } - if r.enableSessionTokenExchangeCode != nil { - localVarQueryParams.Add("return_session_token_exchange_code", parameterToString(*r.enableSessionTokenExchangeCode, "")) + if r.returnSessionTokenExchangeCode != nil { + localVarQueryParams.Add("return_session_token_exchange_code", parameterToString(*r.returnSessionTokenExchangeCode, "")) } if r.returnTo != nil { localVarQueryParams.Add("return_to", parameterToString(*r.returnTo, "")) @@ -2167,12 +2167,12 @@ func (a *FrontendApiService) CreateNativeRecoveryFlowExecute(r FrontendApiApiCre type FrontendApiApiCreateNativeRegistrationFlowRequest struct { ctx context.Context ApiService FrontendApi - enableSessionTokenExchangeCode *bool + returnSessionTokenExchangeCode *bool returnTo *string } -func (r FrontendApiApiCreateNativeRegistrationFlowRequest) EnableSessionTokenExchangeCode(enableSessionTokenExchangeCode bool) FrontendApiApiCreateNativeRegistrationFlowRequest { - r.enableSessionTokenExchangeCode = &enableSessionTokenExchangeCode +func (r FrontendApiApiCreateNativeRegistrationFlowRequest) ReturnSessionTokenExchangeCode(returnSessionTokenExchangeCode bool) FrontendApiApiCreateNativeRegistrationFlowRequest { + r.returnSessionTokenExchangeCode = &returnSessionTokenExchangeCode return r } func (r FrontendApiApiCreateNativeRegistrationFlowRequest) ReturnTo(returnTo string) FrontendApiApiCreateNativeRegistrationFlowRequest { @@ -2240,8 +2240,8 @@ func (a *FrontendApiService) CreateNativeRegistrationFlowExecute(r FrontendApiAp localVarQueryParams := url.Values{} localVarFormParams := url.Values{} - if r.enableSessionTokenExchangeCode != nil { - localVarQueryParams.Add("return_session_token_exchange_code", parameterToString(*r.enableSessionTokenExchangeCode, "")) + if r.returnSessionTokenExchangeCode != nil { + localVarQueryParams.Add("return_session_token_exchange_code", parameterToString(*r.returnSessionTokenExchangeCode, "")) } if r.returnTo != nil { localVarQueryParams.Add("return_to", parameterToString(*r.returnTo, "")) diff --git a/selfservice/strategy/oidc/strategy_login.go b/selfservice/strategy/oidc/strategy_login.go index ee32e5a55a07..4277e312275f 100644 --- a/selfservice/strategy/oidc/strategy_login.go +++ b/selfservice/strategy/oidc/strategy_login.go @@ -104,7 +104,6 @@ func (s *Strategy) processLogin(w http.ResponseWriter, r *http.Request, a *login opts = append(opts, registration.WithFlowReturnTo(a.ReturnTo)) } - // This flow only works for browsers anyways. aa, err := s.d.RegistrationHandler().NewRegistrationFlow(w, r, a.Type, opts...) if err != nil { return nil, s.handleError(w, r, a, provider.Config().ID, nil, err) diff --git a/spec/api.json b/spec/api.json index ea3acb5b1535..d2069aaad3be 100755 --- a/spec/api.json +++ b/spec/api.json @@ -4867,7 +4867,7 @@ { "description": "EnableSessionTokenExchangeCode requests the login flow to include a code that can be used to retrieve the session token\nafter the login flow has been completed.", "in": "query", - "name": "enable_session_token_exchange_code", + "name": "return_session_token_exchange_code", "schema": { "type": "boolean" } @@ -5615,7 +5615,7 @@ { "description": "EnableSessionTokenExchangeCode requests the login flow to include a code that can be used to retrieve the session token\nafter the login flow has been completed.", "in": "query", - "name": "enable_session_token_exchange_code", + "name": "return_session_token_exchange_code", "schema": { "type": "boolean" } diff --git a/spec/swagger.json b/spec/swagger.json index 866e4cf1f358..5155dfb68785 100755 --- a/spec/swagger.json +++ b/spec/swagger.json @@ -1535,7 +1535,7 @@ { "type": "boolean", "description": "EnableSessionTokenExchangeCode requests the login flow to include a code that can be used to retrieve the session token\nafter the login flow has been completed.", - "name": "enable_session_token_exchange_code", + "name": "return_session_token_exchange_code", "in": "query" }, { @@ -2157,7 +2157,7 @@ { "type": "boolean", "description": "EnableSessionTokenExchangeCode requests the login flow to include a code that can be used to retrieve the session token\nafter the login flow has been completed.", - "name": "enable_session_token_exchange_code", + "name": "return_session_token_exchange_code", "in": "query" }, { From 44ff8eae763b62fd046da742d7d836a3f6a3c52f Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Wed, 19 Apr 2023 14:22:56 +0200 Subject: [PATCH 06/15] test: add OIDC strategy test --- selfservice/sessiontokenexchange/handler.go | 4 +- .../strategy/oidc/strategy_helper_test.go | 28 ++ selfservice/strategy/oidc/strategy_test.go | 266 ++++++++++++------ 3 files changed, 209 insertions(+), 89 deletions(-) diff --git a/selfservice/sessiontokenexchange/handler.go b/selfservice/sessiontokenexchange/handler.go index 3cebcaddcbaf..b425a4502c62 100644 --- a/selfservice/sessiontokenexchange/handler.go +++ b/selfservice/sessiontokenexchange/handler.go @@ -63,7 +63,7 @@ type exchangeSessionToken struct { // The Response for Registration Flows via API // // swagger:model successfulCodeExchangeResponse -type codeExchangeResponse struct { +type Response struct { // The Session Token // // A session token is equivalent to a session cookie, but it can be sent in the HTTP Authorization @@ -119,7 +119,7 @@ func (h *Handler) exchangeCode(w http.ResponseWriter, r *http.Request, _ httprou return } - h.d.Writer().Write(w, r, &codeExchangeResponse{ + h.d.Writer().Write(w, r, &Response{ Token: sess.Token, Session: sess, }) diff --git a/selfservice/strategy/oidc/strategy_helper_test.go b/selfservice/strategy/oidc/strategy_helper_test.go index 16089bed8599..31fc70987e48 100644 --- a/selfservice/strategy/oidc/strategy_helper_test.go +++ b/selfservice/strategy/oidc/strategy_helper_test.go @@ -194,6 +194,10 @@ func newHydraIntegration(t *testing.T, remote *string, subject *string, claims * func newReturnTs(t *testing.T, reg driver.Registry) *httptest.Server { ctx := context.Background() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/app_code" { + reg.Writer().Write(w, r, "ok") + return + } sess, err := reg.SessionManager().FetchFromRequest(r.Context(), r) require.NoError(t, err) reg.Writer().Write(w, r, sess) @@ -268,6 +272,30 @@ func newHydra(t *testing.T, subject *string, claims *idTokenClaims, scope *[]str remotePublic = "http://localhost:" + hydra.GetPort("4444/tcp") remoteAdmin = "http://localhost:" + hydra.GetPort("4445/tcp") + + err = resilience.Retry(logrusx.New("", ""), time.Second*1, time.Second*5, func() error { + pr := remotePublic + "/health/ready" + res, err := http.DefaultClient.Get(pr) + if err != nil || res.StatusCode != 200 { + return errors.Errorf("Hydra public is not ready at " + pr) + } + + wellKnown := remotePublic + "/.well-known/openid-configuration" + res, err = http.DefaultClient.Get(wellKnown) + if err != nil || res.StatusCode != 200 { + return errors.Errorf("Hydra .well-known is not ready at " + wellKnown) + } + + ar := remoteAdmin + "/health/ready" + res, err = http.DefaultClient.Get(ar) + if err != nil && res.StatusCode != 200 { + return errors.Errorf("Hydra admin is not ready at " + ar) + } else { + return nil + } + }) + require.NoError(t, err) + } t.Logf("Ory Hydra running at: %s %s", remotePublic, remoteAdmin) diff --git a/selfservice/strategy/oidc/strategy_test.go b/selfservice/strategy/oidc/strategy_test.go index 8fda01141e45..3166acf4e46b 100644 --- a/selfservice/strategy/oidc/strategy_test.go +++ b/selfservice/strategy/oidc/strategy_test.go @@ -17,6 +17,7 @@ import ( "testing" "time" + "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/x/snapshotx" "github.com/ory/kratos/text" @@ -98,7 +99,7 @@ func TestStrategy(t *testing.T) { scope = []string{} // assert form values - var afv = func(t *testing.T, flowID uuid.UUID, provider string) (action string) { + var assertFormValues = func(t *testing.T, flowID uuid.UUID, provider string) (action string) { var config *container.Container if req, err := reg.RegistrationFlowPersister().GetRegistrationFlow(context.Background(), flowID); err == nil { require.EqualValues(t, req.ID, flowID) @@ -153,7 +154,35 @@ func TestStrategy(t *testing.T) { return makeRequestWithCookieJar(t, provider, action, fv, nil) } - var assertSystemError = func(t *testing.T, res *http.Response, body []byte, code int, reason string) { + var makeAPICodeFlowRequest = func(t *testing.T, provider, action string) { + res, err := testhelpers.NewDebugClient(t).Post(action, "application/json", strings.NewReader(fmt.Sprintf(`{ + "method": "oidc", + "provider": %q +}`, provider))) + require.NoError(t, err) + require.Equal(t, http.StatusUnprocessableEntity, res.StatusCode) + var changeLocation flow.BrowserLocationChangeRequiredError + require.NoError(t, json.NewDecoder(res.Body).Decode(&changeLocation)) + + res, err = testhelpers.NewClientWithCookieJar(t, nil, true).Get(changeLocation.RedirectBrowserTo) + require.NoError(t, err) + + assert.Equal(t, returnTS.URL+"/app_code", res.Request.URL.String()) + + return + } + + var exchangeCodeForToken = func(t *testing.T, code string) (codeResponse sessiontokenexchange.Response, err error) { + res, err := ts.Client().Get(ts.URL + "/self-service/exchange-code-for-session-token?code=" + code) + if err != nil { + return codeResponse, err + } + require.NoError(t, json.NewDecoder(res.Body).Decode(&codeResponse)) + + return + } + + var assertSystemErrorWithReason = func(t *testing.T, res *http.Response, body []byte, code int, reason string) { require.Contains(t, res.Request.URL.String(), errTS.URL, "%s", body) assert.Equal(t, int64(code), gjson.GetBytes(body, "code").Int(), "%s", body) @@ -161,31 +190,31 @@ func TestStrategy(t *testing.T) { } // assert system error (redirect to error endpoint) - var asem = func(t *testing.T, res *http.Response, body []byte, code int, reason string) { + var assertSystemErrorWithMessage = func(t *testing.T, res *http.Response, body []byte, code int, message string) { require.Contains(t, res.Request.URL.String(), errTS.URL, "%s", body) assert.Equal(t, int64(code), gjson.GetBytes(body, "code").Int(), "%s", body) - assert.Contains(t, gjson.GetBytes(body, "message").String(), reason, "%s", body) + assert.Contains(t, gjson.GetBytes(body, "message").String(), message, "%s", body) } // assert ui error (redirect to login/registration ui endpoint) - var aue = func(t *testing.T, res *http.Response, body []byte, reason string) { + var assertUIError = func(t *testing.T, res *http.Response, body []byte, reason string) { require.Contains(t, res.Request.URL.String(), uiTS.URL, "status: %d, body: %s", res.StatusCode, body) assert.Contains(t, gjson.GetBytes(body, "ui.messages.0.text").String(), reason, "%s", body) } // assert identity (success) - var ai = func(t *testing.T, res *http.Response, body []byte) { + var assertIdentity = func(t *testing.T, res *http.Response, body []byte) { assert.Contains(t, res.Request.URL.String(), returnTS.URL) assert.Equal(t, subject, gjson.GetBytes(body, "identity.traits.subject").String(), "%s", body) assert.Equal(t, claims.traits.website, gjson.GetBytes(body, "identity.traits.website").String(), "%s", body) assert.Equal(t, claims.metadataPublic.picture, gjson.GetBytes(body, "identity.metadata_public.picture").String(), "%s", body) } - var newLoginFlow = func(t *testing.T, redirectTo string, exp time.Duration) (req *login.Flow) { + var newLoginFlow = func(t *testing.T, redirectTo string, exp time.Duration, flowType flow.Type) (req *login.Flow) { // Use NewLoginFlow to instantiate the request but change the things we need to control a copy of it. req, _, err := reg.LoginHandler().NewLoginFlow(httptest.NewRecorder(), - &http.Request{URL: urlx.ParseOrPanic(redirectTo)}, flow.TypeBrowser) + &http.Request{URL: urlx.ParseOrPanic(redirectTo)}, flowType) require.NoError(t, err) req.RequestURL = redirectTo req.ExpiresAt = time.Now().Add(exp) @@ -199,11 +228,17 @@ func TestStrategy(t *testing.T) { return } + var newBrowserLoginFlow = func(t *testing.T, redirectTo string, exp time.Duration) (req *login.Flow) { + return newLoginFlow(t, redirectTo, exp, flow.TypeBrowser) + } + var newAPILoginFlow = func(t *testing.T, redirectTo string, exp time.Duration) (req *login.Flow) { + return newLoginFlow(t, redirectTo, exp, flow.TypeAPI) + } - var newRegistrationFlow = func(t *testing.T, redirectTo string, exp time.Duration) *registration.Flow { + var newRegistrationFlow = func(t *testing.T, redirectTo string, exp time.Duration, flowType flow.Type) *registration.Flow { // Use NewLoginFlow to instantiate the request but change the things we need to control a copy of it. req, err := reg.RegistrationHandler().NewRegistrationFlow(httptest.NewRecorder(), - &http.Request{URL: urlx.ParseOrPanic(redirectTo)}, flow.TypeBrowser) + &http.Request{URL: urlx.ParseOrPanic(redirectTo)}, flowType) require.NoError(t, err) req.RequestURL = redirectTo req.ExpiresAt = time.Now().Add(exp) @@ -216,15 +251,21 @@ func TestStrategy(t *testing.T) { return req } + var newBrowserRegistrationFlow = func(t *testing.T, redirectTo string, exp time.Duration) *registration.Flow { + return newRegistrationFlow(t, redirectTo, exp, flow.TypeBrowser) + } + var newAPIRegistrationFlow = func(t *testing.T, redirectTo string, exp time.Duration) *registration.Flow { + return newRegistrationFlow(t, redirectTo, exp, flow.TypeAPI) + } t.Run("case=should fail because provider does not exist", func(t *testing.T) { for k, v := range []string{ - loginAction(newLoginFlow(t, returnTS.URL, time.Minute).ID), - registerAction(newRegistrationFlow(t, returnTS.URL, time.Minute).ID), + loginAction(newBrowserLoginFlow(t, returnTS.URL, time.Minute).ID), + registerAction(newBrowserRegistrationFlow(t, returnTS.URL, time.Minute).ID), } { t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { res, body := makeRequest(t, "provider-does-not-exist", v, url.Values{}) - assertSystemError(t, res, body, http.StatusNotFound, "is unknown or has not been configured") + assertSystemErrorWithReason(t, res, body, http.StatusNotFound, "is unknown or has not been configured") }) } }) @@ -232,12 +273,12 @@ func TestStrategy(t *testing.T) { t.Run("case=should fail because the issuer is mismatching", func(t *testing.T) { scope = []string{"openid"} for k, v := range []string{ - loginAction(newLoginFlow(t, returnTS.URL, time.Minute).ID), - registerAction(newRegistrationFlow(t, returnTS.URL, time.Minute).ID), + loginAction(newBrowserLoginFlow(t, returnTS.URL, time.Minute).ID), + registerAction(newBrowserRegistrationFlow(t, returnTS.URL, time.Minute).ID), } { t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { res, body := makeRequest(t, "invalid-issuer", v, url.Values{}) - assertSystemError(t, res, body, http.StatusInternalServerError, "issuer did not match the issuer returned by provider") + assertSystemErrorWithReason(t, res, body, http.StatusInternalServerError, "issuer did not match the issuer returned by provider") }) } }) @@ -246,17 +287,17 @@ func TestStrategy(t *testing.T) { for k, v := range []string{loginAction(x.NewUUID()), registerAction(x.NewUUID())} { t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { res, body := makeRequest(t, "valid", v, url.Values{}) - asem(t, res, body, http.StatusNotFound, "Unable to locate the resource") + assertSystemErrorWithMessage(t, res, body, http.StatusNotFound, "Unable to locate the resource") }) } }) t.Run("case=should fail because the flow is expired", func(t *testing.T) { for k, v := range []uuid.UUID{ - newLoginFlow(t, returnTS.URL, -time.Minute).ID, - newRegistrationFlow(t, returnTS.URL, -time.Minute).ID} { + newBrowserLoginFlow(t, returnTS.URL, -time.Minute).ID, + newBrowserRegistrationFlow(t, returnTS.URL, -time.Minute).ID} { t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { - action := afv(t, v, "valid") + action := assertFormValues(t, v, "valid") res, body := makeRequest(t, "valid", action, url.Values{}) assert.NotEqual(t, v, gjson.GetBytes(body, "id")) @@ -271,12 +312,13 @@ func TestStrategy(t *testing.T) { scope = []string{} for k, v := range []uuid.UUID{ - newLoginFlow(t, returnTS.URL, time.Minute).ID, - newRegistrationFlow(t, returnTS.URL, time.Minute).ID} { + newBrowserLoginFlow(t, returnTS.URL, time.Minute).ID, + newBrowserRegistrationFlow(t, returnTS.URL, time.Minute).ID, + } { t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { - action := afv(t, v, "valid") + action := assertFormValues(t, v, "valid") res, body := makeRequest(t, "valid", action, url.Values{}) - aue(t, res, body, "no id_token was returned") + assertUIError(t, res, body, "no id_token was returned") }) } }) @@ -305,18 +347,18 @@ func TestStrategy(t *testing.T) { }) t.Run("case=should fail login because scope was not provided", func(t *testing.T) { - r := newLoginFlow(t, returnTS.URL, time.Minute) - action := afv(t, r.ID, "valid") + r := newBrowserLoginFlow(t, returnTS.URL, time.Minute) + action := assertFormValues(t, r.ID, "valid") res, body := makeRequest(t, "valid", action, url.Values{}) - aue(t, res, body, "no id_token was returned") + assertUIError(t, res, body, "no id_token was returned") }) t.Run("case=should fail registration flow because subject is not an email", func(t *testing.T) { subject = "not-an-email" scope = []string{"openid"} - r := newRegistrationFlow(t, returnTS.URL, time.Minute) - action := afv(t, r.ID, "valid") + r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute) + action := assertFormValues(t, r.ID, "valid") res, body := makeRequest(t, "valid", action, url.Values{}) require.Contains(t, res.Request.URL.String(), uiTS.URL, "%s", body) @@ -341,20 +383,20 @@ func TestStrategy(t *testing.T) { } t.Run("case=should pass registration", func(t *testing.T) { - r := newRegistrationFlow(t, returnTS.URL, time.Minute) - action := afv(t, r.ID, "valid") + r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute) + action := assertFormValues(t, r.ID, "valid") res, body := makeRequest(t, "valid", action, url.Values{}) - ai(t, res, body) + assertIdentity(t, res, body) expectTokens(t, "valid", body) }) t.Run("case=try another registration", func(t *testing.T) { returnTo := fmt.Sprintf("%s/home?query=true", returnTS.URL) - r := newRegistrationFlow(t, fmt.Sprintf("%s?return_to=%s", returnTS.URL, url.QueryEscape(returnTo)), time.Minute) - action := afv(t, r.ID, "valid") + r := newBrowserRegistrationFlow(t, fmt.Sprintf("%s?return_to=%s", returnTS.URL, url.QueryEscape(returnTo)), time.Minute) + action := assertFormValues(t, r.ID, "valid") res, body := makeRequest(t, "valid", action, url.Values{}) assert.Equal(t, returnTo, res.Request.URL.String()) - ai(t, res, body) + assertIdentity(t, res, body) expectTokens(t, "valid", body) }) }) @@ -377,18 +419,18 @@ func TestStrategy(t *testing.T) { } t.Run("case=should pass registration", func(t *testing.T) { - r := newRegistrationFlow(t, returnTS.URL, time.Minute) - action := afv(t, r.ID, "valid") + r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute) + action := assertFormValues(t, r.ID, "valid") res, body := makeRequest(t, "valid", action, url.Values{}) - ai(t, res, body) + assertIdentity(t, res, body) expectTokens(t, "valid", body) }) t.Run("case=should pass login", func(t *testing.T) { - r := newLoginFlow(t, returnTS.URL, time.Minute) - action := afv(t, r.ID, "valid") + r := newBrowserLoginFlow(t, returnTS.URL, time.Minute) + action := assertFormValues(t, r.ID, "valid") res, body := makeRequest(t, "valid", action, url.Values{}) - ai(t, res, body) + assertIdentity(t, res, body) expectTokens(t, "valid", body) }) }) @@ -398,24 +440,74 @@ func TestStrategy(t *testing.T) { scope = []string{"openid"} t.Run("case=should pass login", func(t *testing.T) { - r := newLoginFlow(t, returnTS.URL, time.Minute) - action := afv(t, r.ID, "valid") + r := newBrowserLoginFlow(t, returnTS.URL, time.Minute) + action := assertFormValues(t, r.ID, "valid") res, body := makeRequest(t, "valid", action, url.Values{}) - ai(t, res, body) + assertIdentity(t, res, body) }) }) + t.Run("suite=API with session token exchange code", func(t *testing.T) { + subject = "api-code-register@ory.sh" + scope = []string{"openid"} + + var loginOrRegister = func(id uuid.UUID, code string) { + _, err := exchangeCodeForToken(t, code) + require.Error(t, err, "session token should not be available yet") + + action := assertFormValues(t, id, "valid") + makeAPICodeFlowRequest(t, "valid", action) + codeResponse, err := exchangeCodeForToken(t, code) + require.NoError(t, err, "session token should be available not w") + + assert.NotEmpty(t, codeResponse.Token) + assert.Equal(t, subject, gjson.GetBytes(codeResponse.Session.Identity.Traits, "subject").String()) + } + var register = func() { + f := newAPIRegistrationFlow(t, returnTS.URL+"?return_session_token_exchange_code=true&return_to=/app_code", 1*time.Minute) + loginOrRegister(f.ID, f.SessionTokenExchangeCode) + } + var login = func() { + f := newAPILoginFlow(t, returnTS.URL+"?return_session_token_exchange_code=true&return_to=/app_code", 1*time.Minute) + loginOrRegister(f.ID, f.SessionTokenExchangeCode) + } + + for _, tc := range []struct { + name string + first, then func() + }{{ + name: "login-twice", + first: login, then: login, + }, { + name: "login-then-register", + first: login, then: register, + }, { + name: "register-then-login", + first: register, then: login, + }, { + name: "register-twice", + first: register, then: register, + }} { + t.Run("case="+tc.name, func(t *testing.T) { + subject = tc.name + "-api-code-testing@ory.sh" + tc.first() + tc.then() + }) + } + + }) + t.Run("case=login without registered account with return_to", func(t *testing.T) { subject = "login-without-register-return-to@ory.sh" scope = []string{"openid"} returnTo := "/foo" t.Run("case=should pass login", func(t *testing.T) { - r := newLoginFlow(t, fmt.Sprintf("%s?return_to=%s", returnTS.URL, returnTo), time.Minute) - action := afv(t, r.ID, "valid") + r := newBrowserLoginFlow(t, fmt.Sprintf("%s?return_to=%s", returnTS.URL, returnTo), time.Minute) + action := assertFormValues(t, r.ID, "valid") res, body := makeRequest(t, "valid", action, url.Values{}) assert.True(t, strings.HasSuffix(res.Request.URL.String(), returnTo)) - ai(t, res, body) + assertIdentity(t, res, body) }) }) @@ -424,26 +516,26 @@ func TestStrategy(t *testing.T) { scope = []string{"openid"} t.Run("case=should pass registration", func(t *testing.T) { - r := newRegistrationFlow(t, returnTS.URL, time.Minute) - action := afv(t, r.ID, "valid") + r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute) + action := assertFormValues(t, r.ID, "valid") res, body := makeRequest(t, "valid", action, url.Values{}) - ai(t, res, body) + assertIdentity(t, res, body) }) t.Run("case=should pass second time registration", func(t *testing.T) { - r := newLoginFlow(t, returnTS.URL, time.Minute) - action := afv(t, r.ID, "valid") + r := newBrowserLoginFlow(t, returnTS.URL, time.Minute) + action := assertFormValues(t, r.ID, "valid") res, body := makeRequest(t, "valid", action, url.Values{}) - ai(t, res, body) + assertIdentity(t, res, body) }) t.Run("case=should pass third time registration with return to", func(t *testing.T) { returnTo := "/foo" - r := newLoginFlow(t, fmt.Sprintf("%s?return_to=%s", returnTS.URL, returnTo), time.Minute) - action := afv(t, r.ID, "valid") + r := newBrowserLoginFlow(t, fmt.Sprintf("%s?return_to=%s", returnTS.URL, returnTo), time.Minute) + action := assertFormValues(t, r.ID, "valid") res, body := makeRequest(t, "valid", action, url.Values{}) assert.True(t, strings.HasSuffix(res.Request.URL.String(), returnTo)) - ai(t, res, body) + assertIdentity(t, res, body) }) }) @@ -457,8 +549,8 @@ func TestStrategy(t *testing.T) { claims.metadataAdmin.phoneNumber = "911" t.Run("case=should fail registration on first attempt", func(t *testing.T) { - r := newRegistrationFlow(t, returnTS.URL, time.Minute) - action := afv(t, r.ID, "valid") + r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute) + action := assertFormValues(t, r.ID, "valid") res, body := makeRequest(t, "valid", action, url.Values{"traits.name": {"i"}}) require.Contains(t, res.Request.URL.String(), uiTS.URL, "%s", body) @@ -469,10 +561,10 @@ func TestStrategy(t *testing.T) { }) t.Run("case=should pass registration with valid data", func(t *testing.T) { - r := newRegistrationFlow(t, returnTS.URL, time.Minute) - action := afv(t, r.ID, "valid") + r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute) + action := assertFormValues(t, r.ID, "valid") res, body := makeRequest(t, "valid", action, url.Values{"traits.name": {"valid-name"}}) - ai(t, res, body) + assertIdentity(t, res, body) assert.Equal(t, "https://www.ory.sh/kratos", gjson.GetBytes(body, "identity.traits.website").String(), "%s", body) assert.Equal(t, "valid-name", gjson.GetBytes(body, "identity.traits.name").String(), "%s", body) assert.Equal(t, "[\"group1\",\"group2\"]", gjson.GetBytes(body, "identity.traits.groups").String(), "%s", body) @@ -494,18 +586,18 @@ func TestStrategy(t *testing.T) { }) t.Run("case=should fail registration", func(t *testing.T) { - r := newRegistrationFlow(t, returnTS.URL, time.Minute) - action := afv(t, r.ID, "valid") + r := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute) + action := assertFormValues(t, r.ID, "valid") res, body := makeRequest(t, "valid", action, url.Values{}) - aue(t, res, body, "An account with the same identifier (email, phone, username, ...) exists already. Please sign in to your existing account and link your social profile in the settings page.") + assertUIError(t, res, body, "An account with the same identifier (email, phone, username, ...) exists already. Please sign in to your existing account and link your social profile in the settings page.") require.Contains(t, gjson.GetBytes(body, "ui.action").String(), "/self-service/login") }) t.Run("case=should fail login", func(t *testing.T) { - r := newLoginFlow(t, returnTS.URL, time.Minute) - action := afv(t, r.ID, "valid") + r := newBrowserLoginFlow(t, returnTS.URL, time.Minute) + action := assertFormValues(t, r.ID, "valid") res, body := makeRequest(t, "valid", action, url.Values{}) - aue(t, res, body, "An account with the same identifier (email, phone, username, ...) exists already.") + assertUIError(t, res, body, "An account with the same identifier (email, phone, username, ...) exists already.") }) }) @@ -515,12 +607,12 @@ func TestStrategy(t *testing.T) { fv := url.Values{"traits.name": {"valid-name"}} jar, _ := cookiejar.New(nil) - r1 := newLoginFlow(t, returnTS.URL, time.Minute) - res1, body1 := makeRequestWithCookieJar(t, "valid", afv(t, r1.ID, "valid"), fv, jar) - ai(t, res1, body1) - r2 := newLoginFlow(t, returnTS.URL, time.Minute) - res2, body2 := makeRequestWithCookieJar(t, "valid", afv(t, r2.ID, "valid"), fv, jar) - ai(t, res2, body2) + r1 := newBrowserLoginFlow(t, returnTS.URL, time.Minute) + res1, body1 := makeRequestWithCookieJar(t, "valid", assertFormValues(t, r1.ID, "valid"), fv, jar) + assertIdentity(t, res1, body1) + r2 := newBrowserLoginFlow(t, returnTS.URL, time.Minute) + res2, body2 := makeRequestWithCookieJar(t, "valid", assertFormValues(t, r2.ID, "valid"), fv, jar) + assertIdentity(t, res2, body2) assert.Equal(t, body1, body2) }) @@ -530,13 +622,13 @@ func TestStrategy(t *testing.T) { fv := url.Values{"traits.name": {"valid-name"}} jar, _ := cookiejar.New(nil) - r1 := newLoginFlow(t, returnTS.URL, time.Minute) - res1, body1 := makeRequestWithCookieJar(t, "valid", afv(t, r1.ID, "valid"), fv, jar) - ai(t, res1, body1) - r2 := newLoginFlow(t, returnTS.URL, time.Minute) + r1 := newBrowserLoginFlow(t, returnTS.URL, time.Minute) + res1, body1 := makeRequestWithCookieJar(t, "valid", assertFormValues(t, r1.ID, "valid"), fv, jar) + assertIdentity(t, res1, body1) + r2 := newBrowserLoginFlow(t, returnTS.URL, time.Minute) require.NoError(t, reg.LoginFlowPersister().ForceLoginFlow(context.Background(), r2.ID)) - res2, body2 := makeRequestWithCookieJar(t, "valid", afv(t, r2.ID, "valid"), fv, jar) - ai(t, res2, body2) + res2, body2 := makeRequestWithCookieJar(t, "valid", assertFormValues(t, r2.ID, "valid"), fv, jar) + assertIdentity(t, res2, body2) assert.NotEqual(t, gjson.GetBytes(body1, "id"), gjson.GetBytes(body2, "id")) authAt1, err := time.Parse(time.RFC3339, gjson.GetBytes(body1, "authenticated_at").String()) require.NoError(t, err) @@ -558,8 +650,8 @@ func TestStrategy(t *testing.T) { } t.Run("case=should pass when registering", func(t *testing.T) { - f := newRegistrationFlow(t, returnTS.URL, time.Minute) - action := afv(t, f.ID, "valid") + f := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute) + action := assertFormValues(t, f.ID, "valid") fv := url.Values{} @@ -579,9 +671,9 @@ func TestStrategy(t *testing.T) { }) t.Run("case=should pass when logging in", func(t *testing.T) { - f := newLoginFlow(t, returnTS.URL, time.Minute) + f := newBrowserLoginFlow(t, returnTS.URL, time.Minute) - action := afv(t, f.ID, "valid") + action := assertFormValues(t, f.ID, "valid") fv := url.Values{} @@ -601,8 +693,8 @@ func TestStrategy(t *testing.T) { }) t.Run("case=should ignore invalid parameters when logging in", func(t *testing.T) { - f := newLoginFlow(t, returnTS.URL, time.Minute) - action := afv(t, f.ID, "valid") + f := newBrowserLoginFlow(t, returnTS.URL, time.Minute) + action := assertFormValues(t, f.ID, "valid") fv := url.Values{} fv.Set("upstream_parameters.lol", "invalid") @@ -619,8 +711,8 @@ func TestStrategy(t *testing.T) { }) t.Run("case=should ignore invalid parameters when registering", func(t *testing.T) { - f := newRegistrationFlow(t, returnTS.URL, time.Minute) - action := afv(t, f.ID, "valid") + f := newBrowserRegistrationFlow(t, returnTS.URL, time.Minute) + action := assertFormValues(t, f.ID, "valid") fv := url.Values{} fv.Set("upstream_parameters.lol", "invalid") From c733110e351ee5442bcf4ae94102ed0dd4cf5562 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Wed, 19 Apr 2023 18:35:07 +0200 Subject: [PATCH 07/15] fix state --- .../sql/persister_sessiontokenexchanger.go | 13 +++-- selfservice/flow/login/error.go | 2 +- selfservice/flow/login/hook.go | 2 +- selfservice/flow/registration/error.go | 2 +- selfservice/flow/registration/hook.go | 2 +- selfservice/hook/session_issuer.go | 2 +- .../sessiontokenexchange/persistence.go | 2 +- .../sessiontokenexchange/test/persistence.go | 6 +- selfservice/strategy/oidc/strategy.go | 57 +++++++++++++++++-- selfservice/strategy/oidc/strategy_login.go | 8 ++- .../strategy/oidc/strategy_registration.go | 8 ++- .../strategy/oidc/strategy_settings.go | 2 +- .../strategy/oidc/strategy_state_test.go | 10 +++- selfservice/strategy/oidc/strategy_test.go | 7 ++- 14 files changed, 91 insertions(+), 32 deletions(-) diff --git a/persistence/sql/persister_sessiontokenexchanger.go b/persistence/sql/persister_sessiontokenexchanger.go index 2509d60f1d2f..e5615f50c917 100644 --- a/persistence/sql/persister_sessiontokenexchanger.go +++ b/persistence/sql/persister_sessiontokenexchanger.go @@ -69,19 +69,20 @@ func (p *Persister) UpdateSessionOnExchanger(ctx context.Context, flowID uuid.UU return sqlcon.HandleError(conn.RawQuery(query, sessionID, flowID, p.NetworkID(ctx)).Exec()) } -func (p *Persister) CodeExistsForFlow(ctx context.Context, flowID uuid.UUID) (found bool, err error) { - ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.CodeExistsForFlow") +func (p *Persister) CodeForFlow(ctx context.Context, flowID uuid.UUID) (code string, found bool, err error) { + ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.CodeForFlow") defer otelx.End(span, &err) + var e sessiontokenexchange.Exchanger switch err = sqlcon.HandleError(p.GetConnection(ctx). Where("flow_id = ? AND nid = ? AND code <> ''", flowID, p.NetworkID(ctx)). - First(&sessiontokenexchange.Exchanger{})); { + First(&e)); { case err == nil: - return true, nil + return e.Code, true, nil case errors.Is(err, sqlcon.ErrNoRows): - return false, nil + return "", false, nil default: - return false, err + return "", false, err } } diff --git a/selfservice/flow/login/error.go b/selfservice/flow/login/error.go index 460b75abe1de..27424dc92111 100644 --- a/selfservice/flow/login/error.go +++ b/selfservice/flow/login/error.go @@ -120,7 +120,7 @@ func (s *ErrorHandler) WriteFlowError(w http.ResponseWriter, r *http.Request, f return } - if hasCode, _ := s.d.SessionTokenExchangePersister().CodeExistsForFlow(r.Context(), f.ID); f.Type == flow.TypeAPI && hasCode { + if _, hasCode, _ := s.d.SessionTokenExchangePersister().CodeForFlow(r.Context(), f.ID); f.Type == flow.TypeAPI && hasCode { http.Redirect(w, r, f.ReturnTo, http.StatusSeeOther) return } diff --git a/selfservice/flow/login/hook.go b/selfservice/flow/login/hook.go index 41485666eaef..7d1b9039f7f2 100644 --- a/selfservice/flow/login/hook.go +++ b/selfservice/flow/login/hook.go @@ -190,7 +190,7 @@ func (e *HookExecutor) PostLoginHook(w http.ResponseWriter, r *http.Request, g n ), ) - if ok, _ := e.d.SessionTokenExchangePersister().CodeExistsForFlow(ctx, a.ID); ok { + if _, ok, _ := e.d.SessionTokenExchangePersister().CodeForFlow(ctx, a.ID); ok { if err = e.d.SessionTokenExchangePersister().UpdateSessionOnExchanger(r.Context(), a.ID, s.ID); err != nil { return errors.WithStack(err) } diff --git a/selfservice/flow/registration/error.go b/selfservice/flow/registration/error.go index 73b3b0a9657c..f06aebf84b2f 100644 --- a/selfservice/flow/registration/error.go +++ b/selfservice/flow/registration/error.go @@ -131,7 +131,7 @@ func (s *ErrorHandler) WriteFlowError( http.Redirect(w, r, f.AppendTo(s.d.Config().SelfServiceFlowRegistrationUI(r.Context())).String(), http.StatusFound) return } - if hasCode, _ := s.d.SessionTokenExchangePersister().CodeExistsForFlow(r.Context(), f.ID); f.Type == flow.TypeAPI && hasCode { + if _, hasCode, _ := s.d.SessionTokenExchangePersister().CodeForFlow(r.Context(), f.ID); f.Type == flow.TypeAPI && hasCode { http.Redirect(w, r, f.ReturnTo, http.StatusSeeOther) return } diff --git a/selfservice/flow/registration/hook.go b/selfservice/flow/registration/hook.go index e9857b64900e..3ed14ad37ff0 100644 --- a/selfservice/flow/registration/hook.go +++ b/selfservice/flow/registration/hook.go @@ -235,7 +235,7 @@ func (e *HookExecutor) PostRegistrationHook(w http.ResponseWriter, r *http.Reque Debug("Post registration execution hooks completed successfully.") if a.Type == flow.TypeAPI || x.IsJSONRequest(r) { - if ok, _ := e.d.SessionTokenExchangePersister().CodeExistsForFlow(r.Context(), a.ID); ok { + if _, ok, _ := e.d.SessionTokenExchangePersister().CodeForFlow(r.Context(), a.ID); ok { if err = e.d.SessionTokenExchangePersister().UpdateSessionOnExchanger(r.Context(), a.ID, s.ID); err != nil { return errors.WithStack(err) } diff --git a/selfservice/hook/session_issuer.go b/selfservice/hook/session_issuer.go index 024e68a645d7..e82eb254b77a 100644 --- a/selfservice/hook/session_issuer.go +++ b/selfservice/hook/session_issuer.go @@ -56,7 +56,7 @@ func (e *SessionIssuer) executePostRegistrationPostPersistHook(w http.ResponseWr } if a.Type == flow.TypeAPI { - if ok, _ := e.r.SessionTokenExchangePersister().CodeExistsForFlow(r.Context(), a.ID); ok { + if _, ok, _ := e.r.SessionTokenExchangePersister().CodeForFlow(r.Context(), a.ID); ok { if err := e.r.SessionTokenExchangePersister().UpdateSessionOnExchanger(r.Context(), a.ID, s.ID); err != nil { return errors.WithStack(err) } diff --git a/selfservice/sessiontokenexchange/persistence.go b/selfservice/sessiontokenexchange/persistence.go index be0aec90efda..2a3e6c067ed9 100644 --- a/selfservice/sessiontokenexchange/persistence.go +++ b/selfservice/sessiontokenexchange/persistence.go @@ -33,7 +33,7 @@ type ( CreateSessionTokenExchanger(ctx context.Context, flowID uuid.UUID, code string) error GetExchangerFromCode(ctx context.Context, code string) (*Exchanger, error) UpdateSessionOnExchanger(ctx context.Context, flowID uuid.UUID, sessionID uuid.UUID) error - CodeExistsForFlow(ctx context.Context, flowID uuid.UUID) (bool, error) + CodeForFlow(ctx context.Context, flowID uuid.UUID) (code string, found bool, err error) MoveToNewFlow(ctx context.Context, oldFlow, newFlow uuid.UUID) error } diff --git a/selfservice/sessiontokenexchange/test/persistence.go b/selfservice/sessiontokenexchange/test/persistence.go index 9e1ce262f8a2..bdffc484c1a2 100644 --- a/selfservice/sessiontokenexchange/test/persistence.go +++ b/selfservice/sessiontokenexchange/test/persistence.go @@ -43,7 +43,7 @@ func TestPersister(ctx context.Context, _ *config.Config, p interface { t.Run("step=create", func(t *testing.T) { require.NoError(t, p.CreateSessionTokenExchanger(ctx, params.flowID, params.code)) - ok, err := p.CodeExistsForFlow(ctx, params.flowID) + _, ok, err := p.CodeForFlow(ctx, params.flowID) assert.True(t, ok) assert.NoError(t, err) }) @@ -59,12 +59,12 @@ func TestPersister(ctx context.Context, _ *config.Config, p interface { }) }) - t.Run("suite=CodeExistsForFlow", func(t *testing.T) { + t.Run("suite=CodeForFlow", func(t *testing.T) { t.Parallel() t.Run("case=returns false for non-existing flow", func(t *testing.T) { t.Parallel() - ok, err := p.CodeExistsForFlow(ctx, uuid.Must(uuid.NewV4())) + _, ok, err := p.CodeForFlow(ctx, uuid.Must(uuid.NewV4())) assert.False(t, ok) assert.NoError(t, err) }) diff --git a/selfservice/strategy/oidc/strategy.go b/selfservice/strategy/oidc/strategy.go index 268d540f5970..293fa8131aec 100644 --- a/selfservice/strategy/oidc/strategy.go +++ b/selfservice/strategy/oidc/strategy.go @@ -6,7 +6,10 @@ package oidc import ( "bytes" "context" + "crypto/sha512" + "encoding/base64" "encoding/json" + "fmt" "net/http" "path/filepath" "strings" @@ -124,8 +127,37 @@ type authCodeContainer struct { TransientPayload json.RawMessage `json:"transient_payload"` } -func generateState(flowID string) string { - return flowID +type State struct { + FlowID string + Data []byte +} + +func (s *State) String() string { + return base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", s.FlowID, s.Data))) +} + +func generateState(flowID string) *State { + return &State{ + FlowID: flowID, + Data: x.NewUUID().Bytes(), + } +} +func (s *State) setCode(code string) { + s.Data = sha512.New().Sum([]byte(code)) +} +func (s *State) codeMatches(code string) bool { + return bytes.Equal(s.Data, sha512.New().Sum([]byte(code))) +} +func parseState(s string) (*State, error) { + raw, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + return nil, err + } + if id, data, ok := bytes.Cut(raw, []byte(":")); !ok { + return nil, errors.New("state has invalid format") + } else { + return &State{FlowID: string(id), Data: data}, nil + } } func (s *Strategy) CountActiveFirstFactorCredentials(cc map[identity.CredentialsType]identity.Credentials) (count int, err error) { @@ -253,14 +285,23 @@ func (s *Strategy) validateCallback(w http.ResponseWriter, r *http.Request) (flo if stateParam == "" { return nil, nil, errors.WithStack(herodot.ErrBadRequest.WithReasonf(`Unable to complete OpenID Connect flow because the OpenID Provider did not return the state query parameter.`)) } + state, err := parseState(stateParam) + if err != nil { + return nil, nil, errors.WithStack(herodot.ErrBadRequest.WithReasonf(`Unable to complete OpenID Connect flow because the state parameter was invalid.`)) + } - f, err := s.validateFlow(r.Context(), r, x.ParseUUID(stateParam)) + f, err := s.validateFlow(r.Context(), r, x.ParseUUID(state.FlowID)) + if err != nil { + return nil, nil, err + } + + tokenCode, hasSessionTokenCode, err := s.d.SessionTokenExchangePersister().CodeForFlow(r.Context(), f.GetID()) if err != nil { return nil, nil, err } cntnr := authCodeContainer{} - if f.GetType() == flow.TypeBrowser { + if f.GetType() == flow.TypeBrowser || !hasSessionTokenCode { if _, err := s.d.ContinuityManager().Continue(r.Context(), w, r, sessionName, continuity.WithPayload(&cntnr)); err != nil { return nil, nil, err } @@ -268,8 +309,12 @@ func (s *Strategy) validateCallback(w http.ResponseWriter, r *http.Request) (flo return nil, &cntnr, errors.WithStack(herodot.ErrBadRequest.WithReasonf(`Unable to complete OpenID Connect flow because the query state parameter does not match the state parameter from the session cookie.`)) } } else { + // We need to validate the tokenCode here + if !state.codeMatches(tokenCode) { + return nil, &cntnr, errors.WithStack(herodot.ErrBadRequest.WithReasonf(`Unable to complete OpenID Connect flow because the query state parameter does not match the state parameter from the code.`)) + } cntnr.State = stateParam - cntnr.FlowID = stateParam + cntnr.FlowID = state.FlowID } if errorParam != "" { @@ -301,7 +346,7 @@ func (s *Strategy) alreadyAuthenticated(w http.ResponseWriter, r *http.Request, // ignore this if it's a settings flow } else if !isForced(f) { if flowID, ok := registrationOrLoginFlowID(f); ok { - if hasCode, _ := s.d.SessionTokenExchangePersister().CodeExistsForFlow(ctx, flowID); hasCode { + if _, hasCode, _ := s.d.SessionTokenExchangePersister().CodeForFlow(ctx, flowID); hasCode { _ = s.d.SessionTokenExchangePersister().UpdateSessionOnExchanger(ctx, flowID, sess.ID) } } diff --git a/selfservice/strategy/oidc/strategy_login.go b/selfservice/strategy/oidc/strategy_login.go index 4277e312275f..7089b798b55a 100644 --- a/selfservice/strategy/oidc/strategy_login.go +++ b/selfservice/strategy/oidc/strategy_login.go @@ -185,11 +185,13 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, if s.alreadyAuthenticated(w, r, req) { return } - state := generateState(f.ID.String()) + if code, hasCode, _ := s.d.SessionTokenExchangePersister().CodeForFlow(r.Context(), f.ID); hasCode { + state.setCode(code) + } if err := s.d.ContinuityManager().Pause(r.Context(), w, r, sessionName, continuity.WithPayload(&authCodeContainer{ - State: state, + State: state.String(), FlowID: f.ID.String(), Traits: p.Traits, }), @@ -207,7 +209,7 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, return nil, err } - codeURL := c.AuthCodeURL(state, append(provider.AuthCodeURLOptions(req), UpstreamParameters(provider, up)...)...) + codeURL := c.AuthCodeURL(state.String(), append(provider.AuthCodeURLOptions(req), UpstreamParameters(provider, up)...)...) if x.IsJSONRequest(r) { s.d.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(codeURL)) } else { diff --git a/selfservice/strategy/oidc/strategy_registration.go b/selfservice/strategy/oidc/strategy_registration.go index 3b879dc215d9..e6ef18f0f31b 100644 --- a/selfservice/strategy/oidc/strategy_registration.go +++ b/selfservice/strategy/oidc/strategy_registration.go @@ -153,11 +153,13 @@ func (s *Strategy) Register(w http.ResponseWriter, r *http.Request, f *registrat if s.alreadyAuthenticated(w, r, req) { return errors.WithStack(registration.ErrAlreadyLoggedIn) } - state := generateState(f.ID.String()) + if code, hasCode, _ := s.d.SessionTokenExchangePersister().CodeForFlow(r.Context(), f.ID); hasCode { + state.setCode(code) + } if err := s.d.ContinuityManager().Pause(r.Context(), w, r, sessionName, continuity.WithPayload(&authCodeContainer{ - State: state, + State: state.String(), FlowID: f.ID.String(), Traits: p.Traits, TransientPayload: f.TransientPayload, @@ -171,7 +173,7 @@ func (s *Strategy) Register(w http.ResponseWriter, r *http.Request, f *registrat return err } - codeURL := c.AuthCodeURL(state, append(provider.AuthCodeURLOptions(req), UpstreamParameters(provider, up)...)...) + codeURL := c.AuthCodeURL(state.String(), append(provider.AuthCodeURLOptions(req), UpstreamParameters(provider, up)...)...) if x.IsJSONRequest(r) { s.d.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(codeURL)) } else { diff --git a/selfservice/strategy/oidc/strategy_settings.go b/selfservice/strategy/oidc/strategy_settings.go index a7703bcf6136..f6ab39edbb97 100644 --- a/selfservice/strategy/oidc/strategy_settings.go +++ b/selfservice/strategy/oidc/strategy_settings.go @@ -356,7 +356,7 @@ func (s *Strategy) initLinkProvider(w http.ResponseWriter, r *http.Request, ctxU return s.handleSettingsError(w, r, ctxUpdate, p, err) } - state := generateState(ctxUpdate.Flow.ID.String()) + state := generateState(ctxUpdate.Flow.ID.String()).String() if err := s.d.ContinuityManager().Pause(r.Context(), w, r, sessionName, continuity.WithPayload(&authCodeContainer{ State: state, diff --git a/selfservice/strategy/oidc/strategy_state_test.go b/selfservice/strategy/oidc/strategy_state_test.go index d9361d330ab1..28302400861d 100644 --- a/selfservice/strategy/oidc/strategy_state_test.go +++ b/selfservice/strategy/oidc/strategy_state_test.go @@ -7,12 +7,18 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/ory/kratos/x" ) func TestGenerateState(t *testing.T) { - state := generateState(x.NewUUID().String()) + flowID := x.NewUUID().String() + state := generateState(flowID).String() assert.NotEmpty(t, state) - t.Logf("state: %s", state) + + s, err := parseState(state) + require.NoError(t, err) + assert.Equal(t, flowID, s.FlowID) + assert.NotEmpty(t, s.Data) } diff --git a/selfservice/strategy/oidc/strategy_test.go b/selfservice/strategy/oidc/strategy_test.go index 3166acf4e46b..fc0e87523351 100644 --- a/selfservice/strategy/oidc/strategy_test.go +++ b/selfservice/strategy/oidc/strategy_test.go @@ -177,6 +177,9 @@ func TestStrategy(t *testing.T) { if err != nil { return codeResponse, err } + if res.StatusCode != 200 { + return codeResponse, fmt.Errorf("got status code %d", res.StatusCode) + } require.NoError(t, json.NewDecoder(res.Body).Decode(&codeResponse)) return @@ -453,12 +456,12 @@ func TestStrategy(t *testing.T) { var loginOrRegister = func(id uuid.UUID, code string) { _, err := exchangeCodeForToken(t, code) - require.Error(t, err, "session token should not be available yet") + require.Error(t, err) action := assertFormValues(t, id, "valid") makeAPICodeFlowRequest(t, "valid", action) codeResponse, err := exchangeCodeForToken(t, code) - require.NoError(t, err, "session token should be available not w") + require.NoError(t, err) assert.NotEmpty(t, codeResponse.Token) assert.Equal(t, subject, gjson.GetBytes(codeResponse.Session.Identity.Traits, "subject").String()) From c5af643e8ff93364562e4759c9c343901f1f0eeb Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Thu, 20 Apr 2023 13:29:43 +0200 Subject: [PATCH 08/15] move session token exchange point --- driver/registry.go | 1 - driver/registry_default.go | 5 - .../registry_default_sessiontokenexchange.go | 8 -- internal/client-go/api_frontend.go | 2 +- internal/httpclient/api_frontend.go | 2 +- selfservice/sessiontokenexchange/handler.go | 126 ------------------ selfservice/strategy/oidc/strategy_test.go | 6 +- session/handler.go | 90 ++++++++++++- 8 files changed, 92 insertions(+), 148 deletions(-) delete mode 100644 selfservice/sessiontokenexchange/handler.go diff --git a/driver/registry.go b/driver/registry.go index a3a250109df3..6bf98a101c75 100644 --- a/driver/registry.go +++ b/driver/registry.go @@ -139,7 +139,6 @@ type Registry interface { verification.HandlerProvider verification.StrategyProvider - sessiontokenexchange.HandlerProvider sessiontokenexchange.PersistenceProvider link.SenderProvider diff --git a/driver/registry_default.go b/driver/registry_default.go index ea655ecb1ec4..f4e9ba3fb040 100644 --- a/driver/registry_default.go +++ b/driver/registry_default.go @@ -11,7 +11,6 @@ import ( "sync" "time" - "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/x/contextx" "github.com/ory/x/jsonnetsecure" @@ -133,8 +132,6 @@ type RegistryDefault struct { selfserviceLoginHandler *login.Handler selfserviceLoginRequestErrorHandler *login.ErrorHandler - sessionTokenExchangeHandler *sessiontokenexchange.Handler - selfserviceSettingsHandler *settings.Handler selfserviceSettingsErrorHandler *settings.ErrorHandler selfserviceSettingsExecutor *settings.HookExecutor @@ -190,7 +187,6 @@ func (m *RegistryDefault) RegisterPublicRoutes(ctx context.Context, router *x.Ro m.SessionHandler().RegisterPublicRoutes(router) m.SelfServiceErrorHandler().RegisterPublicRoutes(router) m.SchemaHandler().RegisterPublicRoutes(router) - m.SessionTokenExchangeHandler().RegisterPublicRoutes(router) m.AllRecoveryStrategies().RegisterPublicRoutes(router) m.RecoveryHandler().RegisterPublicRoutes(router) @@ -210,7 +206,6 @@ func (m *RegistryDefault) RegisterAdminRoutes(ctx context.Context, router *x.Rou m.IdentityHandler().RegisterAdminRoutes(router) m.CourierHandler().RegisterAdminRoutes(router) m.SelfServiceErrorHandler().RegisterAdminRoutes(router) - m.SessionTokenExchangeHandler().RegisterAdminRoutes(router) m.RecoveryHandler().RegisterAdminRoutes(router) m.AllRecoveryStrategies().RegisterAdminRoutes(router) diff --git a/driver/registry_default_sessiontokenexchange.go b/driver/registry_default_sessiontokenexchange.go index 026f900e52d3..8b12ef6aba69 100644 --- a/driver/registry_default_sessiontokenexchange.go +++ b/driver/registry_default_sessiontokenexchange.go @@ -5,14 +5,6 @@ package driver import "github.com/ory/kratos/selfservice/sessiontokenexchange" -func (m *RegistryDefault) SessionTokenExchangeHandler() *sessiontokenexchange.Handler { - if m.sessionTokenExchangeHandler == nil { - m.sessionTokenExchangeHandler = sessiontokenexchange.NewHandler(m) - } - - return m.sessionTokenExchangeHandler -} - func (m *RegistryDefault) SessionTokenExchangePersister() sessiontokenexchange.Persister { return m.Persister() } diff --git a/internal/client-go/api_frontend.go b/internal/client-go/api_frontend.go index 1838d2473560..119d96346dbe 100644 --- a/internal/client-go/api_frontend.go +++ b/internal/client-go/api_frontend.go @@ -2927,7 +2927,7 @@ func (a *FrontendApiService) ExchangeSessionTokenExecute(r FrontendApiApiExchang return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} } - localVarPath := localBasePath + "/self-service/exchange-code-for-session-token" + localVarPath := localBasePath + "/sessions/token-exchange" localVarHeaderParams := make(map[string]string) localVarQueryParams := url.Values{} diff --git a/internal/httpclient/api_frontend.go b/internal/httpclient/api_frontend.go index 1838d2473560..119d96346dbe 100644 --- a/internal/httpclient/api_frontend.go +++ b/internal/httpclient/api_frontend.go @@ -2927,7 +2927,7 @@ func (a *FrontendApiService) ExchangeSessionTokenExecute(r FrontendApiApiExchang return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} } - localVarPath := localBasePath + "/self-service/exchange-code-for-session-token" + localVarPath := localBasePath + "/sessions/token-exchange" localVarHeaderParams := make(map[string]string) localVarQueryParams := url.Values{} diff --git a/selfservice/sessiontokenexchange/handler.go b/selfservice/sessiontokenexchange/handler.go deleted file mode 100644 index b425a4502c62..000000000000 --- a/selfservice/sessiontokenexchange/handler.go +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright © 2023 Ory Corp -// SPDX-License-Identifier: Apache-2.0 - -package sessiontokenexchange - -import ( - "net/http" - - "github.com/julienschmidt/httprouter" - - "github.com/ory/herodot" - "github.com/ory/kratos/driver/config" - "github.com/ory/kratos/session" - "github.com/ory/kratos/x" -) - -const ( - RouteExchangeCodeForSessionToken = "/self-service/exchange-code-for-session-token" // #nosec G101 -) - -type ( - handlerDependencies interface { - PersistenceProvider - config.Provider - x.WriterProvider - session.PersistenceProvider - } - - HandlerProvider interface { - SessionTokenExchangeHandler() *Handler - } - Handler struct { - d handlerDependencies - } -) - -func NewHandler(d handlerDependencies) *Handler { - return &Handler{d: d} -} - -func (h *Handler) RegisterPublicRoutes(public *x.RouterPublic) { - public.GET(RouteExchangeCodeForSessionToken, h.exchangeCode) -} - -func (h *Handler) RegisterAdminRoutes(admin *x.RouterAdmin) { - admin.GET(RouteExchangeCodeForSessionToken, x.RedirectToPublicRoute(h.d)) -} - -// Exchange Session Token Parameters -// -// swagger:parameters exchangeSessionToken -// -//nolint:deadcode,unused -//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions -type exchangeSessionToken struct { - // The Session Token Exchange Code - // - // required: true - // in: query - SessionTokenExchangeCode string `json:"code"` -} - -// The Response for Registration Flows via API -// -// swagger:model successfulCodeExchangeResponse -type Response struct { - // The Session Token - // - // A session token is equivalent to a session cookie, but it can be sent in the HTTP Authorization - // Header: - // - // Authorization: bearer ${session-token} - // - // The session token is only issued for API flows, not for Browser flows! - Token string `json:"session_token,omitempty"` - - // The Session - // - // The session contains information about the user, the session device, and so on. - // This is only available for API flows, not for Browser flows! - // - // required: true - Session *session.Session `json:"session"` -} - -// swagger:route GET /self-service/exchange-code-for-session-token frontend exchangeSessionToken -// -// # Exchange Session Token -// -// Produces: -// - application/json -// -// Schemes: http, https -// -// Responses: -// 200: successfulNativeLogin -// 403: errorGeneric -// 404: errorGeneric -// 410: errorGeneric -// default: errorGeneric -func (h *Handler) exchangeCode(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - code := r.URL.Query().Get("code") - ctx := r.Context() - - if code == "" { - h.d.Writer().WriteError(w, r, herodot.ErrBadRequest.WithReason(`"code" query param must be set`)) - return - } - - e, err := h.d.SessionTokenExchangePersister().GetExchangerFromCode(ctx, code) - if err != nil { - h.d.Writer().WriteError(w, r, herodot.ErrNotFound.WithReason(`no session yet for this "code"`)) - return - } - - sess, err := h.d.SessionPersister().GetSession(ctx, e.SessionID.UUID, session.ExpandDefault) - if err != nil { - h.d.Writer().WriteError(w, r, err) - return - } - - h.d.Writer().Write(w, r, &Response{ - Token: sess.Token, - Session: sess, - }) -} diff --git a/selfservice/strategy/oidc/strategy_test.go b/selfservice/strategy/oidc/strategy_test.go index fc0e87523351..39649015bdd3 100644 --- a/selfservice/strategy/oidc/strategy_test.go +++ b/selfservice/strategy/oidc/strategy_test.go @@ -17,7 +17,7 @@ import ( "testing" "time" - "github.com/ory/kratos/selfservice/sessiontokenexchange" + "github.com/ory/kratos/session" "github.com/ory/x/snapshotx" "github.com/ory/kratos/text" @@ -172,8 +172,8 @@ func TestStrategy(t *testing.T) { return } - var exchangeCodeForToken = func(t *testing.T, code string) (codeResponse sessiontokenexchange.Response, err error) { - res, err := ts.Client().Get(ts.URL + "/self-service/exchange-code-for-session-token?code=" + code) + var exchangeCodeForToken = func(t *testing.T, code string) (codeResponse session.CodeExchangeResponse, err error) { + res, err := ts.Client().Get(ts.URL + "/sessions/token-exchange?code=" + code) if err != nil { return codeResponse, err } diff --git a/session/handler.go b/session/handler.go index c1de13e54f0c..564da6c627d8 100644 --- a/session/handler.go +++ b/session/handler.go @@ -9,6 +9,7 @@ import ( "strconv" "time" + "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/x/pagination/migrationpagination" "github.com/ory/x/pagination/keysetpagination" @@ -36,6 +37,7 @@ type ( x.LoggingProvider x.CSRFProvider config.Provider + sessiontokenexchange.PersistenceProvider } HandlerProvider interface { SessionHandler() *Handler @@ -56,9 +58,10 @@ func NewHandler( } const ( - RouteCollection = "/sessions" - RouteWhoami = RouteCollection + "/whoami" - RouteSession = RouteCollection + "/:id" + RouteCollection = "/sessions" + RouteExchangeCodeForSessionToken = RouteCollection + "/token-exchange" // #nosec G101 + RouteWhoami = RouteCollection + "/whoami" + RouteSession = RouteCollection + "/:id" ) const ( @@ -96,6 +99,8 @@ func (h *Handler) RegisterPublicRoutes(public *x.RouterPublic) { public.DELETE(RouteSession, h.deleteMySession) public.GET(RouteCollection, h.listMySessions) + public.GET(RouteExchangeCodeForSessionToken, h.exchangeCode) + public.DELETE(AdminRouteIdentitiesSessions, x.RedirectToAdminRoute(h.r)) } @@ -903,3 +908,82 @@ func RespondWitherrorGenericOnAuthenticated(h herodot.Writer, err error) httprou h.WriteError(w, r, err) } } + +// Exchange Session Token Parameters +// +// swagger:parameters exchangeSessionToken +// +//nolint:deadcode,unused +//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions +type exchangeSessionToken struct { + // The Session Token Exchange Code + // + // required: true + // in: query + SessionTokenExchangeCode string `json:"code"` +} + +// The Response for Registration Flows via API +// +// swagger:model successfulCodeExchangeResponse +type CodeExchangeResponse struct { + // The Session Token + // + // A session token is equivalent to a session cookie, but it can be sent in the HTTP Authorization + // Header: + // + // Authorization: bearer ${session-token} + // + // The session token is only issued for API flows, not for Browser flows! + Token string `json:"session_token,omitempty"` + + // The Session + // + // The session contains information about the user, the session device, and so on. + // This is only available for API flows, not for Browser flows! + // + // required: true + Session *Session `json:"session"` +} + +// swagger:route GET /sessions/token-exchange frontend exchangeSessionToken +// +// # Exchange Session Token +// +// Produces: +// - application/json +// +// Schemes: http, https +// +// Responses: +// 200: successfulNativeLogin +// 403: errorGeneric +// 404: errorGeneric +// 410: errorGeneric +// default: errorGeneric +func (h *Handler) exchangeCode(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + code := r.URL.Query().Get("code") + ctx := r.Context() + + if code == "" { + h.r.Writer().WriteError(w, r, herodot.ErrBadRequest.WithReason(`"code" query param must be set`)) + return + } + + e, err := h.r.SessionTokenExchangePersister().GetExchangerFromCode(ctx, code) + if err != nil { + h.r.Writer().WriteError(w, r, herodot.ErrNotFound.WithReason(`no session yet for this "code"`)) + return + } + + sess, err := h.r.SessionPersister().GetSession(ctx, e.SessionID.UUID, ExpandDefault) + if err != nil { + h.r.Writer().WriteError(w, r, err) + return + } + + h.r.Writer().Write(w, r, &CodeExchangeResponse{ + Token: sess.Token, + Session: sess, + }) +} From cfbc327b89911795a62b07fc43a704fe6f943152 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Fri, 21 Apr 2023 10:43:28 +0200 Subject: [PATCH 09/15] add return_to code --- ...reate_session_token_exchanges.mysql.up.sql | 5 +- ...0001_create_session_token_exchanges.up.sql | 6 +- .../sql/persister_sessiontokenexchanger.go | 38 ++++++++----- selfservice/flow/login/handler.go | 8 +-- selfservice/flow/login/hook.go | 8 +-- selfservice/flow/registration/handler.go | 7 +-- selfservice/flow/registration/hook.go | 8 +-- selfservice/hook/session_issuer.go | 13 +---- .../sessiontokenexchange/persistence.go | 15 +++-- .../sessiontokenexchange/test/persistence.go | 56 +++++++++++++------ selfservice/strategy/oidc/strategy.go | 2 +- selfservice/strategy/oidc/strategy_login.go | 2 +- .../strategy/oidc/strategy_registration.go | 2 +- selfservice/strategy/oidc/strategy_test.go | 44 +++++++++------ session/handler.go | 25 ++++++--- session/manager.go | 5 ++ session/manager_http.go | 28 ++++++++++ 17 files changed, 173 insertions(+), 99 deletions(-) diff --git a/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.mysql.up.sql b/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.mysql.up.sql index 31573333b47a..7a113475cb8a 100644 --- a/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.mysql.up.sql +++ b/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.mysql.up.sql @@ -3,7 +3,8 @@ CREATE TABLE session_token_exchanges ( nid CHAR(36) NOT NULL, flow_id CHAR(36) NOT NULL, session_id CHAR(36) DEFAULT NULL, - code VARCHAR(64) NOT NULL, + init_code VARCHAR(64) NOT NULL, + return_to_code VARCHAR(64) NOT NULL, created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP @@ -12,7 +13,7 @@ CREATE TABLE session_token_exchanges ( -- Relevant query: -- SELECT * from session_token_exchanges -- WHERE nid = ? AND code = ? AND code <> '' AND session_id IS NOT NULL -CREATE INDEX session_token_exchanges_nid_code_idx ON session_token_exchanges (code, nid); +CREATE INDEX session_token_exchanges_nid_code_idx ON session_token_exchanges (init_code, nid); -- Relevant query: -- UPDATE session_token_exchanges SET session_id = ? WHERE flow_id = ? AND nid = ? diff --git a/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.up.sql b/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.up.sql index a1ab357955b7..d43f02d20c4f 100644 --- a/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.up.sql +++ b/persistence/sql/migrations/sql/20230405000000000001_create_session_token_exchanges.up.sql @@ -3,7 +3,9 @@ CREATE TABLE session_token_exchanges ( "nid" UUID NOT NULL, "flow_id" UUID NOT NULL, "session_id" UUID DEFAULT NULL, - "code" VARCHAR(64) NOT NULL, + "init_code" VARCHAR(64) NOT NULL, + "return_to_code" VARCHAR(64) NOT NULL, + "created_at" timestamp NOT NULL, "updated_at" timestamp NOT NULL @@ -12,7 +14,7 @@ CREATE TABLE session_token_exchanges ( -- Relevant query: -- SELECT * from session_token_exchanges -- WHERE nid = ? AND code = ? AND code <> '' AND session_id IS NOT NULL -CREATE INDEX session_token_exchanges_nid_code_idx ON session_token_exchanges (code, nid); +CREATE INDEX session_token_exchanges_nid_code_idx ON session_token_exchanges (init_code, nid); -- Relevant query: -- UPDATE session_token_exchanges SET session_id = ? WHERE flow_id = ? AND nid = ? diff --git a/persistence/sql/persister_sessiontokenexchanger.go b/persistence/sql/persister_sessiontokenexchanger.go index e5615f50c917..ed9647a61c57 100644 --- a/persistence/sql/persister_sessiontokenexchanger.go +++ b/persistence/sql/persister_sessiontokenexchanger.go @@ -13,6 +13,7 @@ import ( "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/x/otelx" + "github.com/ory/x/randx" "github.com/ory/x/sqlcon" ) @@ -28,28 +29,32 @@ func updateLimitClause(conn *pop.Connection) string { } } -func (p *Persister) CreateSessionTokenExchanger(ctx context.Context, flowID uuid.UUID, code string) (err error) { +func (p *Persister) CreateSessionTokenExchanger(ctx context.Context, flowID uuid.UUID) (e *sessiontokenexchange.Exchanger, err error) { ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.CreateSessionTokenExchanger") defer otelx.End(span, &err) - e := sessiontokenexchange.Exchanger{ - NID: p.NetworkID(ctx), - FlowID: flowID, - Code: code, + e = &sessiontokenexchange.Exchanger{ + NID: p.NetworkID(ctx), + FlowID: flowID, + InitCode: randx.MustString(64, randx.AlphaNum), + ReturnToCode: randx.MustString(64, randx.AlphaNum), } - return p.GetConnection(ctx).Create(&e) + return e, p.GetConnection(ctx).Create(e) } -func (p *Persister) GetExchangerFromCode(ctx context.Context, code string) (e *sessiontokenexchange.Exchanger, err error) { +func (p *Persister) GetExchangerFromCode(ctx context.Context, initCode string, returnToCode string) (e *sessiontokenexchange.Exchanger, err error) { ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.GetExchangerFromCode") defer otelx.End(span, &err) e = new(sessiontokenexchange.Exchanger) conn := p.GetConnection(ctx) - if err = conn.Where( - "nid = ? AND code = ? AND session_id IS NOT NULL AND code <> ''", - p.NetworkID(ctx), code).First(e); err != nil { + if err = conn.Where(` +nid = ? AND +init_code = ? AND init_code <> '' AND +return_to_code = ? AND return_to_code <> '' AND +session_id IS NOT NULL`, + p.NetworkID(ctx), initCode, returnToCode).First(e); err != nil { return nil, sqlcon.HandleError(err) } @@ -69,20 +74,23 @@ func (p *Persister) UpdateSessionOnExchanger(ctx context.Context, flowID uuid.UU return sqlcon.HandleError(conn.RawQuery(query, sessionID, flowID, p.NetworkID(ctx)).Exec()) } -func (p *Persister) CodeForFlow(ctx context.Context, flowID uuid.UUID) (code string, found bool, err error) { +func (p *Persister) CodeForFlow(ctx context.Context, flowID uuid.UUID) (codes *sessiontokenexchange.Codes, found bool, err error) { ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.CodeForFlow") defer otelx.End(span, &err) var e sessiontokenexchange.Exchanger switch err = sqlcon.HandleError(p.GetConnection(ctx). - Where("flow_id = ? AND nid = ? AND code <> ''", flowID, p.NetworkID(ctx)). + Where("flow_id = ? AND nid = ? AND init_code <> '' and return_to_code <> ''", flowID, p.NetworkID(ctx)). First(&e)); { case err == nil: - return e.Code, true, nil + return &sessiontokenexchange.Codes{ + InitCode: e.InitCode, + ReturnToCode: e.ReturnToCode, + }, true, nil case errors.Is(err, sqlcon.ErrNoRows): - return "", false, nil + return nil, false, nil default: - return "", false, err + return nil, false, err } } diff --git a/selfservice/flow/login/handler.go b/selfservice/flow/login/handler.go index 515944177370..7af617126ef9 100644 --- a/selfservice/flow/login/handler.go +++ b/selfservice/flow/login/handler.go @@ -15,7 +15,6 @@ import ( "github.com/ory/kratos/hydra" "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/kratos/text" - "github.com/ory/x/randx" "github.com/ory/x/stringsx" "github.com/ory/nosurf" @@ -137,14 +136,11 @@ func (h *Handler) NewLoginFlow(w http.ResponseWriter, r *http.Request, ft flow.T } if ft == flow.TypeAPI && r.URL.Query().Get("return_session_token_exchange_code") == "true" { - // Panicing here is ok since it will return a 500 to the user, which is accurate for when - // we can't generate a random string. - f.SessionTokenExchangeCode = randx.MustString(64, randx.AlphaNum) - - err = h.d.SessionTokenExchangePersister().CreateSessionTokenExchanger(r.Context(), f.ID, f.SessionTokenExchangeCode) + e, err := h.d.SessionTokenExchangePersister().CreateSessionTokenExchanger(r.Context(), f.ID) if err != nil { return nil, nil, errors.WithStack(herodot.ErrInternalServerError.WithWrap(err)) } + f.SessionTokenExchangeCode = e.InitCode } // We assume an error means the user has no session diff --git a/selfservice/flow/login/hook.go b/selfservice/flow/login/hook.go index 0d3b1738fb0b..2859a8731502 100644 --- a/selfservice/flow/login/hook.go +++ b/selfservice/flow/login/hook.go @@ -183,11 +183,9 @@ func (e *HookExecutor) PostLoginHook(w http.ResponseWriter, r *http.Request, g n trace.SpanFromContext(r.Context()).AddEvent(events.NewSessionIssued(r.Context(), s.ID, i.ID)) - if _, ok, _ := e.d.SessionTokenExchangePersister().CodeForFlow(ctx, a.ID); ok { - if err = e.d.SessionTokenExchangePersister().UpdateSessionOnExchanger(r.Context(), a.ID, s.ID); err != nil { - return errors.WithStack(err) - } - http.Redirect(w, r, returnTo.String(), http.StatusFound) + if handled, err := e.d.SessionManager().MaybeRedirectAPICodeFlow(w, r, a, s.ID); err != nil { + return errors.WithStack(err) + } else if handled { return nil } diff --git a/selfservice/flow/registration/handler.go b/selfservice/flow/registration/handler.go index 0f4ee24d8189..8ab8040fc483 100644 --- a/selfservice/flow/registration/handler.go +++ b/selfservice/flow/registration/handler.go @@ -12,8 +12,6 @@ import ( "github.com/ory/kratos/hydra" "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/kratos/text" - "github.com/ory/x/randx" - "github.com/ory/nosurf" "github.com/ory/kratos/schema" @@ -126,12 +124,11 @@ func (h *Handler) NewRegistrationFlow(w http.ResponseWriter, r *http.Request, ft if ft == flow.TypeAPI && r.URL.Query().Get("return_session_token_exchange_code") == "true" { // Panicing here is ok since it will return a 500 to the user, which is accurate for when // we can't generate a random string. - f.SessionTokenExchangeCode = randx.MustString(64, randx.AlphaNum) - - err = h.d.SessionTokenExchangePersister().CreateSessionTokenExchanger(r.Context(), f.ID, f.SessionTokenExchangeCode) + e, err := h.d.SessionTokenExchangePersister().CreateSessionTokenExchanger(r.Context(), f.ID) if err != nil { return nil, errors.WithStack(herodot.ErrInternalServerError.WithWrap(err)) } + f.SessionTokenExchangeCode = e.InitCode } for _, s := range h.d.RegistrationStrategies(r.Context()) { diff --git a/selfservice/flow/registration/hook.go b/selfservice/flow/registration/hook.go index 135c9a0e8355..b3a23a066602 100644 --- a/selfservice/flow/registration/hook.go +++ b/selfservice/flow/registration/hook.go @@ -228,11 +228,9 @@ func (e *HookExecutor) PostRegistrationHook(w http.ResponseWriter, r *http.Reque Debug("Post registration execution hooks completed successfully.") if a.Type == flow.TypeAPI || x.IsJSONRequest(r) { - if _, ok, _ := e.d.SessionTokenExchangePersister().CodeForFlow(r.Context(), a.ID); ok { - if err = e.d.SessionTokenExchangePersister().UpdateSessionOnExchanger(r.Context(), a.ID, s.ID); err != nil { - return errors.WithStack(err) - } - http.Redirect(w, r, returnTo.String(), http.StatusFound) + if handled, err := e.d.SessionManager().MaybeRedirectAPICodeFlow(w, r, a, s.ID); err != nil { + return errors.WithStack(err) + } else if handled { return nil } diff --git a/selfservice/hook/session_issuer.go b/selfservice/hook/session_issuer.go index a8a4287092b9..e042a56e66b0 100644 --- a/selfservice/hook/session_issuer.go +++ b/selfservice/hook/session_issuer.go @@ -62,16 +62,9 @@ func (e *SessionIssuer) executePostRegistrationPostPersistHook(w http.ResponseWr trace.SpanFromContext(r.Context()).AddEvent(events.NewSessionIssued(r.Context(), s.ID, s.IdentityID)) if a.Type == flow.TypeAPI { - if _, ok, _ := e.r.SessionTokenExchangePersister().CodeForFlow(r.Context(), a.ID); ok { - if err := e.r.SessionTokenExchangePersister().UpdateSessionOnExchanger(r.Context(), a.ID, s.ID); err != nil { - return errors.WithStack(err) - } - returnTo, err := x.SecureRedirectTo(r, e.r.Config().SelfServiceBrowserDefaultReturnTo(r.Context()), a.SecureRedirectToOpts(r.Context(), e.r)...) - if err != nil { - return errors.WithStack(err) - } - - http.Redirect(w, r, returnTo.String(), http.StatusFound) + if handled, err := e.r.SessionManager().MaybeRedirectAPICodeFlow(w, r, a, s.ID); err != nil { + return errors.WithStack(err) + } else if handled { return nil } diff --git a/selfservice/sessiontokenexchange/persistence.go b/selfservice/sessiontokenexchange/persistence.go index 2a3e6c067ed9..986d7bd1d2cb 100644 --- a/selfservice/sessiontokenexchange/persistence.go +++ b/selfservice/sessiontokenexchange/persistence.go @@ -10,12 +10,19 @@ import ( "github.com/gofrs/uuid" ) +type Codes struct { + InitCode string + ReturnToCode string +} + type Exchanger struct { ID uuid.UUID `db:"id"` NID uuid.UUID `db:"nid"` FlowID uuid.UUID `db:"flow_id"` SessionID uuid.NullUUID `db:"session_id"` - Code string `db:"code"` + + InitCode string `db:"init_code"` + ReturnToCode string `db:"return_to_code"` // CreatedAt is a helper struct field for gobuffalo.pop. CreatedAt time.Time `db:"created_at"` @@ -30,10 +37,10 @@ func (e *Exchanger) TableName() string { type ( Persister interface { - CreateSessionTokenExchanger(ctx context.Context, flowID uuid.UUID, code string) error - GetExchangerFromCode(ctx context.Context, code string) (*Exchanger, error) + CreateSessionTokenExchanger(ctx context.Context, flowID uuid.UUID) (e *Exchanger, err error) + GetExchangerFromCode(ctx context.Context, initCode string, returnToCode string) (*Exchanger, error) UpdateSessionOnExchanger(ctx context.Context, flowID uuid.UUID, sessionID uuid.UUID) error - CodeForFlow(ctx context.Context, flowID uuid.UUID) (code string, found bool, err error) + CodeForFlow(ctx context.Context, flowID uuid.UUID) (codes *Codes, found bool, err error) MoveToNewFlow(ctx context.Context, oldFlow, newFlow uuid.UUID) error } diff --git a/selfservice/sessiontokenexchange/test/persistence.go b/selfservice/sessiontokenexchange/test/persistence.go index bdffc484c1a2..f389016658b9 100644 --- a/selfservice/sessiontokenexchange/test/persistence.go +++ b/selfservice/sessiontokenexchange/test/persistence.go @@ -14,21 +14,27 @@ import ( "github.com/ory/kratos/driver/config" "github.com/ory/kratos/internal/testhelpers" "github.com/ory/kratos/persistence" + "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/x/randx" ) type testParams struct { - flowID, sessionID uuid.UUID - code string + flowID, sessionID uuid.UUID + initCode, returnToCode string } func newParams() testParams { return testParams{ - flowID: uuid.Must(uuid.NewV4()), - sessionID: uuid.Must(uuid.NewV4()), - code: randx.MustString(64, randx.AlphaNum), + flowID: uuid.Must(uuid.NewV4()), + sessionID: uuid.Must(uuid.NewV4()), + initCode: randx.MustString(64, randx.AlphaNum), + returnToCode: randx.MustString(64, randx.AlphaNum), } } +func (t *testParams) setCodes(e *sessiontokenexchange.Exchanger) { + t.initCode = e.InitCode + t.returnToCode = e.ReturnToCode +} func TestPersister(ctx context.Context, _ *config.Config, p interface { persistence.Persister @@ -42,7 +48,9 @@ func TestPersister(ctx context.Context, _ *config.Config, p interface { params := newParams() t.Run("step=create", func(t *testing.T) { - require.NoError(t, p.CreateSessionTokenExchanger(ctx, params.flowID, params.code)) + e, err := p.CreateSessionTokenExchanger(ctx, params.flowID) + require.NoError(t, err) + params.setCodes(e) _, ok, err := p.CodeForFlow(ctx, params.flowID) assert.True(t, ok) assert.NoError(t, err) @@ -51,7 +59,7 @@ func TestPersister(ctx context.Context, _ *config.Config, p interface { require.NoError(t, p.UpdateSessionOnExchanger(ctx, params.flowID, params.sessionID)) }) t.Run("step=get", func(t *testing.T) { - e, err := p.GetExchangerFromCode(ctx, params.code) + e, err := p.GetExchangerFromCode(ctx, params.initCode, params.returnToCode) require.NoError(t, err) assert.Equal(t, params.sessionID, e.SessionID.UUID) @@ -77,11 +85,13 @@ func TestPersister(ctx context.Context, _ *config.Config, p interface { params := newParams() other := newParams() - require.NoError(t, p.CreateSessionTokenExchanger(ctx, params.flowID, params.code)) + e, err := p.CreateSessionTokenExchanger(ctx, params.flowID) + require.NoError(t, err) + params.setCodes(e) require.NoError(t, p.MoveToNewFlow(ctx, params.flowID, other.flowID)) require.NoError(t, p.UpdateSessionOnExchanger(ctx, other.flowID, params.sessionID)) - e, err := p.GetExchangerFromCode(ctx, params.code) + e, err = p.GetExchangerFromCode(ctx, params.initCode, params.returnToCode) require.NoError(t, err) assert.Equal(t, params.sessionID, e.SessionID.UUID) }) @@ -94,8 +104,11 @@ func TestPersister(ctx context.Context, _ *config.Config, p interface { t.Parallel() params := newParams() - require.NoError(t, p.CreateSessionTokenExchanger(ctx, params.flowID, params.code)) - e, err := p.GetExchangerFromCode(ctx, params.code) + e, err := p.CreateSessionTokenExchanger(ctx, params.flowID) + require.NoError(t, err) + params.setCodes(e) + + e, err = p.GetExchangerFromCode(ctx, params.initCode, params.returnToCode) assert.Error(t, err) assert.Nil(t, e) @@ -106,9 +119,12 @@ func TestPersister(ctx context.Context, _ *config.Config, p interface { params := newParams() other := newParams() - require.NoError(t, p.CreateSessionTokenExchanger(ctx, params.flowID, params.code)) + e, err := p.CreateSessionTokenExchanger(ctx, params.flowID) + require.NoError(t, err) + params.setCodes(e) + require.NoError(t, p.UpdateSessionOnExchanger(ctx, params.flowID, params.sessionID)) - e, err := p.GetExchangerFromCode(ctx, other.code) + e, err = p.GetExchangerFromCode(ctx, other.initCode, other.returnToCode) assert.Error(t, err) assert.Nil(t, e) @@ -118,9 +134,12 @@ func TestPersister(ctx context.Context, _ *config.Config, p interface { t.Parallel() params := newParams() - require.NoError(t, p.CreateSessionTokenExchanger(ctx, params.flowID, "")) + e, err := p.CreateSessionTokenExchanger(ctx, params.flowID) + require.NoError(t, err) + params.setCodes(e) + require.NoError(t, p.UpdateSessionOnExchanger(ctx, params.flowID, params.sessionID)) - e, err := p.GetExchangerFromCode(ctx, "") + e, err = p.GetExchangerFromCode(ctx, "", "") assert.Error(t, err) assert.Nil(t, e) @@ -131,9 +150,12 @@ func TestPersister(ctx context.Context, _ *config.Config, p interface { params := newParams() otherNID := uuid.Must(uuid.NewV4()) - require.NoError(t, p.CreateSessionTokenExchanger(ctx, params.flowID, params.code)) + e, err := p.CreateSessionTokenExchanger(ctx, params.flowID) + require.NoError(t, err) + params.setCodes(e) + require.NoError(t, p.UpdateSessionOnExchanger(ctx, params.flowID, params.sessionID)) - e, err := p.WithNetworkID(otherNID).GetExchangerFromCode(ctx, params.code) + e, err = p.WithNetworkID(otherNID).GetExchangerFromCode(ctx, params.initCode, params.returnToCode) assert.Error(t, err) assert.Nil(t, e) diff --git a/selfservice/strategy/oidc/strategy.go b/selfservice/strategy/oidc/strategy.go index 293fa8131aec..65dc619e6a8d 100644 --- a/selfservice/strategy/oidc/strategy.go +++ b/selfservice/strategy/oidc/strategy.go @@ -310,7 +310,7 @@ func (s *Strategy) validateCallback(w http.ResponseWriter, r *http.Request) (flo } } else { // We need to validate the tokenCode here - if !state.codeMatches(tokenCode) { + if !state.codeMatches(tokenCode.InitCode) { return nil, &cntnr, errors.WithStack(herodot.ErrBadRequest.WithReasonf(`Unable to complete OpenID Connect flow because the query state parameter does not match the state parameter from the code.`)) } cntnr.State = stateParam diff --git a/selfservice/strategy/oidc/strategy_login.go b/selfservice/strategy/oidc/strategy_login.go index 7089b798b55a..31d03e05847b 100644 --- a/selfservice/strategy/oidc/strategy_login.go +++ b/selfservice/strategy/oidc/strategy_login.go @@ -187,7 +187,7 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, } state := generateState(f.ID.String()) if code, hasCode, _ := s.d.SessionTokenExchangePersister().CodeForFlow(r.Context(), f.ID); hasCode { - state.setCode(code) + state.setCode(code.InitCode) } if err := s.d.ContinuityManager().Pause(r.Context(), w, r, sessionName, continuity.WithPayload(&authCodeContainer{ diff --git a/selfservice/strategy/oidc/strategy_registration.go b/selfservice/strategy/oidc/strategy_registration.go index e6ef18f0f31b..affc87020cfe 100644 --- a/selfservice/strategy/oidc/strategy_registration.go +++ b/selfservice/strategy/oidc/strategy_registration.go @@ -155,7 +155,7 @@ func (s *Strategy) Register(w http.ResponseWriter, r *http.Request, f *registrat } state := generateState(f.ID.String()) if code, hasCode, _ := s.d.SessionTokenExchangePersister().CodeForFlow(r.Context(), f.ID); hasCode { - state.setCode(code) + state.setCode(code.InitCode) } if err := s.d.ContinuityManager().Pause(r.Context(), w, r, sessionName, continuity.WithPayload(&authCodeContainer{ diff --git a/selfservice/strategy/oidc/strategy_test.go b/selfservice/strategy/oidc/strategy_test.go index 39649015bdd3..e141436f1441 100644 --- a/selfservice/strategy/oidc/strategy_test.go +++ b/selfservice/strategy/oidc/strategy_test.go @@ -17,6 +17,7 @@ import ( "testing" "time" + "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/kratos/session" "github.com/ory/x/snapshotx" @@ -154,7 +155,7 @@ func TestStrategy(t *testing.T) { return makeRequestWithCookieJar(t, provider, action, fv, nil) } - var makeAPICodeFlowRequest = func(t *testing.T, provider, action string) { + var makeAPICodeFlowRequest = func(t *testing.T, provider, action string) (returnToCode string) { res, err := testhelpers.NewDebugClient(t).Post(action, "application/json", strings.NewReader(fmt.Sprintf(`{ "method": "oidc", "provider": %q @@ -167,13 +168,20 @@ func TestStrategy(t *testing.T) { res, err = testhelpers.NewClientWithCookieJar(t, nil, true).Get(changeLocation.RedirectBrowserTo) require.NoError(t, err) - assert.Equal(t, returnTS.URL+"/app_code", res.Request.URL.String()) + returnToURL := res.Request.URL + assert.True(t, strings.HasPrefix(returnToURL.String(), returnTS.URL+"/app_code")) - return + code := returnToURL.Query().Get("code") + assert.NotEmpty(t, code, "code query param was empty in the return_to URL") + + return code } - var exchangeCodeForToken = func(t *testing.T, code string) (codeResponse session.CodeExchangeResponse, err error) { - res, err := ts.Client().Get(ts.URL + "/sessions/token-exchange?code=" + code) + var exchangeCodeForToken = func(t *testing.T, codes sessiontokenexchange.Codes) (codeResponse session.CodeExchangeResponse, err error) { + tokenURL := urlx.ParseOrPanic(ts.URL) + tokenURL.Path = "/sessions/token-exchange" + tokenURL.RawQuery = fmt.Sprintf("init_code=%s&return_to_code=%s", codes.InitCode, codes.ReturnToCode) + res, err := ts.Client().Get(tokenURL.String()) if err != nil { return codeResponse, err } @@ -451,33 +459,35 @@ func TestStrategy(t *testing.T) { }) t.Run("suite=API with session token exchange code", func(t *testing.T) { - subject = "api-code-register@ory.sh" scope = []string{"openid"} - var loginOrRegister = func(id uuid.UUID, code string) { - _, err := exchangeCodeForToken(t, code) + var loginOrRegister = func(t *testing.T, id uuid.UUID, code string) { + _, err := exchangeCodeForToken(t, sessiontokenexchange.Codes{InitCode: code}) require.Error(t, err) action := assertFormValues(t, id, "valid") - makeAPICodeFlowRequest(t, "valid", action) - codeResponse, err := exchangeCodeForToken(t, code) + returnToCode := makeAPICodeFlowRequest(t, "valid", action) + codeResponse, err := exchangeCodeForToken(t, sessiontokenexchange.Codes{ + InitCode: code, + ReturnToCode: returnToCode, + }) require.NoError(t, err) assert.NotEmpty(t, codeResponse.Token) assert.Equal(t, subject, gjson.GetBytes(codeResponse.Session.Identity.Traits, "subject").String()) } - var register = func() { + var register = func(t *testing.T) { f := newAPIRegistrationFlow(t, returnTS.URL+"?return_session_token_exchange_code=true&return_to=/app_code", 1*time.Minute) - loginOrRegister(f.ID, f.SessionTokenExchangeCode) + loginOrRegister(t, f.ID, f.SessionTokenExchangeCode) } - var login = func() { + var login = func(t *testing.T) { f := newAPILoginFlow(t, returnTS.URL+"?return_session_token_exchange_code=true&return_to=/app_code", 1*time.Minute) - loginOrRegister(f.ID, f.SessionTokenExchangeCode) + loginOrRegister(t, f.ID, f.SessionTokenExchangeCode) } for _, tc := range []struct { name string - first, then func() + first, then func(*testing.T) }{{ name: "login-twice", first: login, then: login, @@ -493,8 +503,8 @@ func TestStrategy(t *testing.T) { }} { t.Run("case="+tc.name, func(t *testing.T) { subject = tc.name + "-api-code-testing@ory.sh" - tc.first() - tc.then() + tc.first(t) + tc.then(t) }) } diff --git a/session/handler.go b/session/handler.go index 564da6c627d8..36afabe768e7 100644 --- a/session/handler.go +++ b/session/handler.go @@ -916,11 +916,17 @@ func RespondWitherrorGenericOnAuthenticated(h herodot.Writer, err error) httprou //nolint:deadcode,unused //lint:ignore U1000 Used to generate Swagger and OpenAPI definitions type exchangeSessionToken struct { - // The Session Token Exchange Code + // The part of the code return when initializing the flow. // // required: true // in: query - SessionTokenExchangeCode string `json:"code"` + InitCode string `json:"init_code"` + + // The part of the code returned by the return_to URL. + // + // required: true + // in: query + ReturnToCode string `json:"return_to_code"` } // The Response for Registration Flows via API @@ -962,15 +968,18 @@ type CodeExchangeResponse struct { // 410: errorGeneric // default: errorGeneric func (h *Handler) exchangeCode(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - code := r.URL.Query().Get("code") - ctx := r.Context() - - if code == "" { - h.r.Writer().WriteError(w, r, herodot.ErrBadRequest.WithReason(`"code" query param must be set`)) + var ( + ctx = r.Context() + initCode = r.URL.Query().Get("init_code") + returnToCode = r.URL.Query().Get("return_to_code") + ) + + if initCode == "" || returnToCode == "" { + h.r.Writer().WriteError(w, r, herodot.ErrBadRequest.WithReason(`"init_code" and "return_to_code" query params must be set`)) return } - e, err := h.r.SessionTokenExchangePersister().GetExchangerFromCode(ctx, code) + e, err := h.r.SessionTokenExchangePersister().GetExchangerFromCode(ctx, initCode, returnToCode) if err != nil { h.r.Writer().WriteError(w, r, herodot.ErrNotFound.WithReason(`no session yet for this "code"`)) return diff --git a/session/manager.go b/session/manager.go index 89ff2dbe7dff..1d906e883112 100644 --- a/session/manager.go +++ b/session/manager.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" + "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/text" "github.com/ory/kratos/x/swagger" @@ -136,6 +137,10 @@ type Manager interface { // SessionAddAuthenticationMethods adds one or more authentication method to the session. SessionAddAuthenticationMethods(ctx context.Context, sid uuid.UUID, methods ...AuthenticationMethod) error + + // MaybeRedirectAPICodeFlow for API+Code flows redirects the user to the return_to URL and adds the code query parameter. + // `handled` is true if the request a redirect was written, false otherwise. + MaybeRedirectAPICodeFlow(w http.ResponseWriter, r *http.Request, f flow.Flow, sessionID uuid.UUID) (handled bool, err error) } type ManagementProvider interface { diff --git a/session/manager_http.go b/session/manager_http.go index a756e5bfe7e7..1f5d1eac2a03 100644 --- a/session/manager_http.go +++ b/session/manager_http.go @@ -9,6 +9,8 @@ import ( "net/url" "time" + "github.com/ory/kratos/selfservice/flow" + "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/x/otelx" "github.com/ory/x/randx" @@ -41,6 +43,7 @@ type ( x.CSRFProvider x.TracingProvider PersistenceProvider + sessiontokenexchange.PersistenceProvider } ManagerHTTP struct { cookieName func(ctx context.Context) string @@ -320,3 +323,28 @@ func (s *ManagerHTTP) SessionAddAuthenticationMethods(ctx context.Context, sid u sess.SetAuthenticatorAssuranceLevel() return s.r.SessionPersister().UpsertSession(ctx, sess) } + +func (s *ManagerHTTP) MaybeRedirectAPICodeFlow(w http.ResponseWriter, r *http.Request, f flow.Flow, sessionID uuid.UUID) (handled bool, err error) { + ctx, span := s.r.Tracer(r.Context()).Tracer().Start(r.Context(), "sessions.ManagerHTTP.MaybeRedirectAPICodeFlow") + defer otelx.End(span, &err) + + if code, ok, _ := s.r.SessionTokenExchangePersister().CodeForFlow(ctx, f.GetID()); ok { + returnTo := s.r.Config().SelfServiceBrowserDefaultReturnTo(ctx) + if redirecter, ok := f.(flow.FlowWithRedirect); ok { + r, err := x.SecureRedirectTo(r, returnTo, redirecter.SecureRedirectToOpts(ctx, s.r)...) + if err == nil { + returnTo = r + } + } + + if err = s.r.SessionTokenExchangePersister().UpdateSessionOnExchanger(r.Context(), f.GetID(), sessionID); err != nil { + return false, errors.WithStack(err) + } + + returnTo.RawQuery = "code=" + code.ReturnToCode + http.Redirect(w, r, returnTo.String(), http.StatusSeeOther) + + return true, nil + } + return false, nil +} From a5188a07554e093d4af5eaa7fbf3c0729e430235 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Fri, 21 Apr 2023 20:22:30 +0200 Subject: [PATCH 10/15] address review comments --- .../sessiontokenexchange/test/persistence.go | 4 ++- selfservice/strategy/oidc/strategy.go | 15 +++++--- selfservice/strategy/oidc/strategy_login.go | 6 ++-- .../strategy/oidc/strategy_registration.go | 5 ++- session/manager_http.go | 34 +++++++++++-------- 5 files changed, 40 insertions(+), 24 deletions(-) diff --git a/selfservice/sessiontokenexchange/test/persistence.go b/selfservice/sessiontokenexchange/test/persistence.go index f389016658b9..53db63db04f3 100644 --- a/selfservice/sessiontokenexchange/test/persistence.go +++ b/selfservice/sessiontokenexchange/test/persistence.go @@ -51,9 +51,11 @@ func TestPersister(ctx context.Context, _ *config.Config, p interface { e, err := p.CreateSessionTokenExchanger(ctx, params.flowID) require.NoError(t, err) params.setCodes(e) - _, ok, err := p.CodeForFlow(ctx, params.flowID) + codes, ok, err := p.CodeForFlow(ctx, params.flowID) assert.True(t, ok) assert.NoError(t, err) + assert.Equal(t, params.initCode, codes.InitCode) + assert.Equal(t, params.returnToCode, codes.ReturnToCode) }) t.Run("step=update", func(t *testing.T) { require.NoError(t, p.UpdateSessionOnExchanger(ctx, params.flowID, params.sessionID)) diff --git a/selfservice/strategy/oidc/strategy.go b/selfservice/strategy/oidc/strategy.go index 65dc619e6a8d..aa296df7e98d 100644 --- a/selfservice/strategy/oidc/strategy.go +++ b/selfservice/strategy/oidc/strategy.go @@ -338,7 +338,7 @@ func registrationOrLoginFlowID(flow any) (uuid.UUID, bool) { } } -func (s *Strategy) alreadyAuthenticated(w http.ResponseWriter, r *http.Request, f interface{}) bool { +func (s *Strategy) alreadyAuthenticated(w http.ResponseWriter, r *http.Request, f interface{}) (bool, error) { ctx := r.Context() if sess, _ := s.d.SessionManager().FetchFromRequest(ctx, r); sess != nil { @@ -347,7 +347,10 @@ func (s *Strategy) alreadyAuthenticated(w http.ResponseWriter, r *http.Request, } else if !isForced(f) { if flowID, ok := registrationOrLoginFlowID(f); ok { if _, hasCode, _ := s.d.SessionTokenExchangePersister().CodeForFlow(ctx, flowID); hasCode { - _ = s.d.SessionTokenExchangePersister().UpdateSessionOnExchanger(ctx, flowID, sess.ID) + err := s.d.SessionTokenExchangePersister().UpdateSessionOnExchanger(ctx, flowID, sess.ID) + if err != nil { + return false, err + } } } returnTo := s.d.Config().SelfServiceBrowserDefaultReturnTo(ctx) @@ -358,11 +361,11 @@ func (s *Strategy) alreadyAuthenticated(w http.ResponseWriter, r *http.Request, } } http.Redirect(w, r, returnTo.String(), http.StatusSeeOther) - return true + return true, nil } } - return false + return false, nil } func (s *Strategy) handleCallback(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { @@ -381,7 +384,9 @@ func (s *Strategy) handleCallback(w http.ResponseWriter, r *http.Request, ps htt return } - if s.alreadyAuthenticated(w, r, req) { + if authenticated, err := s.alreadyAuthenticated(w, r, req); err != nil { + s.forwardError(w, r, req, s.handleError(w, r, req, pid, nil, err)) + } else if authenticated { return } diff --git a/selfservice/strategy/oidc/strategy_login.go b/selfservice/strategy/oidc/strategy_login.go index 31d03e05847b..fa4d98974fcb 100644 --- a/selfservice/strategy/oidc/strategy_login.go +++ b/selfservice/strategy/oidc/strategy_login.go @@ -182,8 +182,10 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, return nil, s.handleError(w, r, f, pid, nil, err) } - if s.alreadyAuthenticated(w, r, req) { - return + if authenticated, err := s.alreadyAuthenticated(w, r, req); err != nil { + return nil, s.handleError(w, r, f, pid, nil, err) + } else if authenticated { + return i, nil } state := generateState(f.ID.String()) if code, hasCode, _ := s.d.SessionTokenExchangePersister().CodeForFlow(r.Context(), f.ID); hasCode { diff --git a/selfservice/strategy/oidc/strategy_registration.go b/selfservice/strategy/oidc/strategy_registration.go index affc87020cfe..1d7ae3e2b33a 100644 --- a/selfservice/strategy/oidc/strategy_registration.go +++ b/selfservice/strategy/oidc/strategy_registration.go @@ -150,9 +150,12 @@ func (s *Strategy) Register(w http.ResponseWriter, r *http.Request, f *registrat return s.handleError(w, r, f, pid, nil, err) } - if s.alreadyAuthenticated(w, r, req) { + if authenticated, err := s.alreadyAuthenticated(w, r, req); err != nil { + return s.handleError(w, r, f, pid, nil, err) + } else if authenticated { return errors.WithStack(registration.ErrAlreadyLoggedIn) } + state := generateState(f.ID.String()) if code, hasCode, _ := s.d.SessionTokenExchangePersister().CodeForFlow(r.Context(), f.ID); hasCode { state.setCode(code.InitCode) diff --git a/session/manager_http.go b/session/manager_http.go index 1f5d1eac2a03..b808140f2a63 100644 --- a/session/manager_http.go +++ b/session/manager_http.go @@ -328,23 +328,27 @@ func (s *ManagerHTTP) MaybeRedirectAPICodeFlow(w http.ResponseWriter, r *http.Re ctx, span := s.r.Tracer(r.Context()).Tracer().Start(r.Context(), "sessions.ManagerHTTP.MaybeRedirectAPICodeFlow") defer otelx.End(span, &err) - if code, ok, _ := s.r.SessionTokenExchangePersister().CodeForFlow(ctx, f.GetID()); ok { - returnTo := s.r.Config().SelfServiceBrowserDefaultReturnTo(ctx) - if redirecter, ok := f.(flow.FlowWithRedirect); ok { - r, err := x.SecureRedirectTo(r, returnTo, redirecter.SecureRedirectToOpts(ctx, s.r)...) - if err == nil { - returnTo = r - } - } + code, ok, _ := s.r.SessionTokenExchangePersister().CodeForFlow(ctx, f.GetID()) + if !ok { + return false, nil + } - if err = s.r.SessionTokenExchangePersister().UpdateSessionOnExchanger(r.Context(), f.GetID(), sessionID); err != nil { - return false, errors.WithStack(err) + returnTo := s.r.Config().SelfServiceBrowserDefaultReturnTo(ctx) + if redirecter, ok := f.(flow.FlowWithRedirect); ok { + r, err := x.SecureRedirectTo(r, returnTo, redirecter.SecureRedirectToOpts(ctx, s.r)...) + if err == nil { + returnTo = r } + } - returnTo.RawQuery = "code=" + code.ReturnToCode - http.Redirect(w, r, returnTo.String(), http.StatusSeeOther) - - return true, nil + if err = s.r.SessionTokenExchangePersister().UpdateSessionOnExchanger(r.Context(), f.GetID(), sessionID); err != nil { + return false, errors.WithStack(err) } - return false, nil + + q := returnTo.Query() + q.Set("code", code.ReturnToCode) + returnTo.RawQuery = q.Encode() + http.Redirect(w, r, returnTo.String(), http.StatusSeeOther) + + return true, nil } From 2d821762bb336ff48ee42313d60d33b14d89a8b0 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Mon, 24 Apr 2023 11:45:43 +0200 Subject: [PATCH 11/15] Add Playwright Make target --- Makefile | 6 + internal/client-go/README.md | 2 +- internal/client-go/api_frontend.go | 25 +++- internal/httpclient/README.md | 2 +- internal/httpclient/api_frontend.go | 25 +++- spec/api.json | 153 +++++++++++--------- spec/swagger.json | 121 ++++++++-------- test/e2e/package.json | 4 +- test/e2e/playwright.config.ts | 63 ++++++++ test/e2e/playwright/setup/default_config.ts | 2 +- 10 files changed, 253 insertions(+), 150 deletions(-) create mode 100644 test/e2e/playwright.config.ts diff --git a/Makefile b/Makefile index d56b1f937163..ebe0bbec16e4 100644 --- a/Makefile +++ b/Makefile @@ -177,6 +177,12 @@ test-e2e: node_modules test-resetdb test/e2e/run.sh cockroach test/e2e/run.sh mysql +.PHONY: test-e2e-playwright +test-e2e-playwright: node_modules test-resetdb + source script/test-envs.sh + test/e2e/run.sh --only-setup + (cd test/e2e; DB=memory npm run playwright) + .PHONY: migrations-sync migrations-sync: .bin/ory ory dev pop migration sync persistence/sql/migrations/templates persistence/sql/migratest/testdata diff --git a/internal/client-go/README.md b/internal/client-go/README.md index 4549734d1e2c..cb48b260e91f 100644 --- a/internal/client-go/README.md +++ b/internal/client-go/README.md @@ -94,7 +94,7 @@ Class | Method | HTTP request | Description *FrontendApi* | [**CreateNativeVerificationFlow**](docs/FrontendApi.md#createnativeverificationflow) | **Get** /self-service/verification/api | Create Verification Flow for Native Apps *FrontendApi* | [**DisableMyOtherSessions**](docs/FrontendApi.md#disablemyothersessions) | **Delete** /sessions | Disable my other sessions *FrontendApi* | [**DisableMySession**](docs/FrontendApi.md#disablemysession) | **Delete** /sessions/{id} | Disable one of my sessions -*FrontendApi* | [**ExchangeSessionToken**](docs/FrontendApi.md#exchangesessiontoken) | **Get** /self-service/exchange-code-for-session-token | Exchange Session Token +*FrontendApi* | [**ExchangeSessionToken**](docs/FrontendApi.md#exchangesessiontoken) | **Get** /sessions/token-exchange | Exchange Session Token *FrontendApi* | [**GetFlowError**](docs/FrontendApi.md#getflowerror) | **Get** /self-service/errors | Get User-Flow Errors *FrontendApi* | [**GetLoginFlow**](docs/FrontendApi.md#getloginflow) | **Get** /self-service/login/flows | Get Login Flow *FrontendApi* | [**GetRecoveryFlow**](docs/FrontendApi.md#getrecoveryflow) | **Get** /self-service/recovery/flows | Get Recovery Flow diff --git a/internal/client-go/api_frontend.go b/internal/client-go/api_frontend.go index 119d96346dbe..d4a8a65a13a3 100644 --- a/internal/client-go/api_frontend.go +++ b/internal/client-go/api_frontend.go @@ -2882,13 +2882,18 @@ func (a *FrontendApiService) DisableMySessionExecute(r FrontendApiApiDisableMySe } type FrontendApiApiExchangeSessionTokenRequest struct { - ctx context.Context - ApiService FrontendApi - code *string + ctx context.Context + ApiService FrontendApi + initCode *string + returnToCode *string } -func (r FrontendApiApiExchangeSessionTokenRequest) Code(code string) FrontendApiApiExchangeSessionTokenRequest { - r.code = &code +func (r FrontendApiApiExchangeSessionTokenRequest) InitCode(initCode string) FrontendApiApiExchangeSessionTokenRequest { + r.initCode = &initCode + return r +} +func (r FrontendApiApiExchangeSessionTokenRequest) ReturnToCode(returnToCode string) FrontendApiApiExchangeSessionTokenRequest { + r.returnToCode = &returnToCode return r } @@ -2932,11 +2937,15 @@ func (a *FrontendApiService) ExchangeSessionTokenExecute(r FrontendApiApiExchang localVarHeaderParams := make(map[string]string) localVarQueryParams := url.Values{} localVarFormParams := url.Values{} - if r.code == nil { - return localVarReturnValue, nil, reportError("code is required and must be specified") + if r.initCode == nil { + return localVarReturnValue, nil, reportError("initCode is required and must be specified") + } + if r.returnToCode == nil { + return localVarReturnValue, nil, reportError("returnToCode is required and must be specified") } - localVarQueryParams.Add("code", parameterToString(*r.code, "")) + localVarQueryParams.Add("init_code", parameterToString(*r.initCode, "")) + localVarQueryParams.Add("return_to_code", parameterToString(*r.returnToCode, "")) // to determine the Content-Type header localVarHTTPContentTypes := []string{} diff --git a/internal/httpclient/README.md b/internal/httpclient/README.md index 4549734d1e2c..cb48b260e91f 100644 --- a/internal/httpclient/README.md +++ b/internal/httpclient/README.md @@ -94,7 +94,7 @@ Class | Method | HTTP request | Description *FrontendApi* | [**CreateNativeVerificationFlow**](docs/FrontendApi.md#createnativeverificationflow) | **Get** /self-service/verification/api | Create Verification Flow for Native Apps *FrontendApi* | [**DisableMyOtherSessions**](docs/FrontendApi.md#disablemyothersessions) | **Delete** /sessions | Disable my other sessions *FrontendApi* | [**DisableMySession**](docs/FrontendApi.md#disablemysession) | **Delete** /sessions/{id} | Disable one of my sessions -*FrontendApi* | [**ExchangeSessionToken**](docs/FrontendApi.md#exchangesessiontoken) | **Get** /self-service/exchange-code-for-session-token | Exchange Session Token +*FrontendApi* | [**ExchangeSessionToken**](docs/FrontendApi.md#exchangesessiontoken) | **Get** /sessions/token-exchange | Exchange Session Token *FrontendApi* | [**GetFlowError**](docs/FrontendApi.md#getflowerror) | **Get** /self-service/errors | Get User-Flow Errors *FrontendApi* | [**GetLoginFlow**](docs/FrontendApi.md#getloginflow) | **Get** /self-service/login/flows | Get Login Flow *FrontendApi* | [**GetRecoveryFlow**](docs/FrontendApi.md#getrecoveryflow) | **Get** /self-service/recovery/flows | Get Recovery Flow diff --git a/internal/httpclient/api_frontend.go b/internal/httpclient/api_frontend.go index 119d96346dbe..d4a8a65a13a3 100644 --- a/internal/httpclient/api_frontend.go +++ b/internal/httpclient/api_frontend.go @@ -2882,13 +2882,18 @@ func (a *FrontendApiService) DisableMySessionExecute(r FrontendApiApiDisableMySe } type FrontendApiApiExchangeSessionTokenRequest struct { - ctx context.Context - ApiService FrontendApi - code *string + ctx context.Context + ApiService FrontendApi + initCode *string + returnToCode *string } -func (r FrontendApiApiExchangeSessionTokenRequest) Code(code string) FrontendApiApiExchangeSessionTokenRequest { - r.code = &code +func (r FrontendApiApiExchangeSessionTokenRequest) InitCode(initCode string) FrontendApiApiExchangeSessionTokenRequest { + r.initCode = &initCode + return r +} +func (r FrontendApiApiExchangeSessionTokenRequest) ReturnToCode(returnToCode string) FrontendApiApiExchangeSessionTokenRequest { + r.returnToCode = &returnToCode return r } @@ -2932,11 +2937,15 @@ func (a *FrontendApiService) ExchangeSessionTokenExecute(r FrontendApiApiExchang localVarHeaderParams := make(map[string]string) localVarQueryParams := url.Values{} localVarFormParams := url.Values{} - if r.code == nil { - return localVarReturnValue, nil, reportError("code is required and must be specified") + if r.initCode == nil { + return localVarReturnValue, nil, reportError("initCode is required and must be specified") + } + if r.returnToCode == nil { + return localVarReturnValue, nil, reportError("returnToCode is required and must be specified") } - localVarQueryParams.Add("code", parameterToString(*r.code, "")) + localVarQueryParams.Add("init_code", parameterToString(*r.initCode, "")) + localVarQueryParams.Add("return_to_code", parameterToString(*r.returnToCode, "")) // to determine the Content-Type header localVarHTTPContentTypes := []string{} diff --git a/spec/api.json b/spec/api.json index d2069aaad3be..3d694a3acc01 100755 --- a/spec/api.json +++ b/spec/api.json @@ -4655,78 +4655,6 @@ ] } }, - "/self-service/exchange-code-for-session-token": { - "get": { - "operationId": "exchangeSessionToken", - "parameters": [ - { - "description": "The Session Token Exchange Code", - "in": "query", - "name": "code", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/successfulNativeLogin" - } - } - }, - "description": "successfulNativeLogin" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/errorGeneric" - } - } - }, - "description": "errorGeneric" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/errorGeneric" - } - } - }, - "description": "errorGeneric" - }, - "410": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/errorGeneric" - } - } - }, - "description": "errorGeneric" - }, - "default": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/errorGeneric" - } - } - }, - "description": "errorGeneric" - } - }, - "summary": "Exchange Session Token", - "tags": [ - "frontend" - ] - } - }, "/self-service/login": { "post": { "description": ":::info\n\nThis endpoint is EXPERIMENTAL and subject to potential breaking changes in the future.\n\n:::\n\nUse this endpoint to complete a login flow. This endpoint\nbehaves differently for API and browser flows.\n\nAPI flows expect `application/json` to be sent in the body and responds with\nHTTP 200 and a application/json body with the session token on success;\nHTTP 410 if the original flow expired with the appropriate error messages set and optionally a `use_flow_id` parameter in the body;\nHTTP 400 on form validation errors.\n\nBrowser flows expect a Content-Type of `application/x-www-form-urlencoded` or `application/json` to be sent in the body and respond with\na HTTP 303 redirect to the post/after login URL or the `return_to` value if it was set and if the login succeeded;\na HTTP 303 redirect to the login UI URL with the flow ID containing the validation errors otherwise.\n\nBrowser flows with an accept header of `application/json` will not redirect but instead respond with\nHTTP 200 and a application/json body with the signed in identity and a `Set-Cookie` header on success;\nHTTP 303 redirect to a fresh login flow if the original flow expired with the appropriate error messages set;\nHTTP 400 on form validation errors.\n\nIf this endpoint is called with `Accept: application/json` in the header, the response contains the flow without a redirect. In the\ncase of an error, the `error.id` of the JSON response body can be one of:\n\n`session_already_available`: The user is already signed in.\n`security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred.\n`security_identity_mismatch`: The requested `?return_to` address is not allowed to be used. Adjust this in the configuration!\n`browser_location_change_required`: Usually sent when an AJAX request indicates that the browser needs to open a specific URL.\nMost likely used in Social Sign In flows.\n\nMore information can be found at [Ory Kratos User Login](https://www.ory.sh/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.sh/docs/kratos/self-service/flows/user-registration).", @@ -6586,6 +6514,87 @@ ] } }, + "/sessions/token-exchange": { + "get": { + "operationId": "exchangeSessionToken", + "parameters": [ + { + "description": "The part of the code return when initializing the flow.", + "in": "query", + "name": "init_code", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "The part of the code returned by the return_to URL.", + "in": "query", + "name": "return_to_code", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/successfulNativeLogin" + } + } + }, + "description": "successfulNativeLogin" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/errorGeneric" + } + } + }, + "description": "errorGeneric" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/errorGeneric" + } + } + }, + "description": "errorGeneric" + }, + "410": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/errorGeneric" + } + } + }, + "description": "errorGeneric" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/errorGeneric" + } + } + }, + "description": "errorGeneric" + } + }, + "summary": "Exchange Session Token", + "tags": [ + "frontend" + ] + } + }, "/sessions/whoami": { "get": { "description": "Uses the HTTP Headers in the GET request to determine (e.g. by using checking the cookies) who is authenticated.\nReturns a session object in the body or 401 if the credentials are invalid or no credentials were sent.\nWhen the request it successful it adds the user ID to the 'X-Kratos-Authenticated-Identity-Id' header\nin the response.\n\nIf you call this endpoint from a server-side application, you must forward the HTTP Cookie Header to this endpoint:\n\n```js\npseudo-code example\nrouter.get('/protected-endpoint', async function (req, res) {\nconst session = await client.toSession(undefined, req.header('cookie'))\n\nconsole.log(session)\n})\n```\n\nWhen calling this endpoint from a non-browser application (e.g. mobile app) you must include the session token:\n\n```js\npseudo-code example\n...\nconst session = await client.toSession(\"the-session-token\")\n\nconsole.log(session)\n```\n\nDepending on your configuration this endpoint might return a 403 status code if the session has a lower Authenticator\nAssurance Level (AAL) than is possible for the identity. This can happen if the identity has password + webauthn\ncredentials (which would result in AAL2) but the session has only AAL1. If this error occurs, ask the user\nto sign in with the second factor or change the configuration.\n\nThis endpoint is useful for:\n\nAJAX calls. Remember to send credentials and set up CORS correctly!\nReverse proxies and API Gateways\nServer-side calls - use the `X-Session-Token` header!\n\nThis endpoint authenticates users by checking:\n\nif the `Cookie` HTTP header was set containing an Ory Kratos Session Cookie;\nif the `Authorization: bearer \u003cory-session-token\u003e` HTTP header was set with a valid Ory Kratos Session Token;\nif the `X-Session-Token` HTTP header was set with a valid Ory Kratos Session Token.\n\nIf none of these headers are set or the cooke or token are invalid, the endpoint returns a HTTP 401 status code.\n\nAs explained above, this request may fail due to several reasons. The `error.id` can be one of:\n\n`session_inactive`: No active session was found in the request (e.g. no Ory Session Cookie / Ory Session Token).\n`session_aal2_required`: An active session was found but it does not fulfil the Authenticator Assurance Level, implying that the session must (e.g.) authenticate the second factor.", diff --git a/spec/swagger.json b/spec/swagger.json index 5155dfb68785..142650176c4c 100755 --- a/spec/swagger.json +++ b/spec/swagger.json @@ -1354,63 +1354,6 @@ } } }, - "/self-service/exchange-code-for-session-token": { - "get": { - "produces": [ - "application/json" - ], - "schemes": [ - "http", - "https" - ], - "tags": [ - "frontend" - ], - "summary": "Exchange Session Token", - "operationId": "exchangeSessionToken", - "parameters": [ - { - "type": "string", - "description": "The Session Token Exchange Code", - "name": "code", - "in": "query", - "required": true - } - ], - "responses": { - "200": { - "description": "successfulNativeLogin", - "schema": { - "$ref": "#/definitions/successfulNativeLogin" - } - }, - "403": { - "description": "errorGeneric", - "schema": { - "$ref": "#/definitions/errorGeneric" - } - }, - "404": { - "description": "errorGeneric", - "schema": { - "$ref": "#/definitions/errorGeneric" - } - }, - "410": { - "description": "errorGeneric", - "schema": { - "$ref": "#/definitions/errorGeneric" - } - }, - "default": { - "description": "errorGeneric", - "schema": { - "$ref": "#/definitions/errorGeneric" - } - } - } - } - }, "/self-service/login": { "post": { "description": ":::info\n\nThis endpoint is EXPERIMENTAL and subject to potential breaking changes in the future.\n\n:::\n\nUse this endpoint to complete a login flow. This endpoint\nbehaves differently for API and browser flows.\n\nAPI flows expect `application/json` to be sent in the body and responds with\nHTTP 200 and a application/json body with the session token on success;\nHTTP 410 if the original flow expired with the appropriate error messages set and optionally a `use_flow_id` parameter in the body;\nHTTP 400 on form validation errors.\n\nBrowser flows expect a Content-Type of `application/x-www-form-urlencoded` or `application/json` to be sent in the body and respond with\na HTTP 303 redirect to the post/after login URL or the `return_to` value if it was set and if the login succeeded;\na HTTP 303 redirect to the login UI URL with the flow ID containing the validation errors otherwise.\n\nBrowser flows with an accept header of `application/json` will not redirect but instead respond with\nHTTP 200 and a application/json body with the signed in identity and a `Set-Cookie` header on success;\nHTTP 303 redirect to a fresh login flow if the original flow expired with the appropriate error messages set;\nHTTP 400 on form validation errors.\n\nIf this endpoint is called with `Accept: application/json` in the header, the response contains the flow without a redirect. In the\ncase of an error, the `error.id` of the JSON response body can be one of:\n\n`session_already_available`: The user is already signed in.\n`security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred.\n`security_identity_mismatch`: The requested `?return_to` address is not allowed to be used. Adjust this in the configuration!\n`browser_location_change_required`: Usually sent when an AJAX request indicates that the browser needs to open a specific URL.\nMost likely used in Social Sign In flows.\n\nMore information can be found at [Ory Kratos User Login](https://www.ory.sh/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.sh/docs/kratos/self-service/flows/user-registration).", @@ -2922,6 +2865,70 @@ } } }, + "/sessions/token-exchange": { + "get": { + "produces": [ + "application/json" + ], + "schemes": [ + "http", + "https" + ], + "tags": [ + "frontend" + ], + "summary": "Exchange Session Token", + "operationId": "exchangeSessionToken", + "parameters": [ + { + "type": "string", + "description": "The part of the code return when initializing the flow.", + "name": "init_code", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "The part of the code returned by the return_to URL.", + "name": "return_to_code", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "successfulNativeLogin", + "schema": { + "$ref": "#/definitions/successfulNativeLogin" + } + }, + "403": { + "description": "errorGeneric", + "schema": { + "$ref": "#/definitions/errorGeneric" + } + }, + "404": { + "description": "errorGeneric", + "schema": { + "$ref": "#/definitions/errorGeneric" + } + }, + "410": { + "description": "errorGeneric", + "schema": { + "$ref": "#/definitions/errorGeneric" + } + }, + "default": { + "description": "errorGeneric", + "schema": { + "$ref": "#/definitions/errorGeneric" + } + } + } + } + }, "/sessions/whoami": { "get": { "description": "Uses the HTTP Headers in the GET request to determine (e.g. by using checking the cookies) who is authenticated.\nReturns a session object in the body or 401 if the credentials are invalid or no credentials were sent.\nWhen the request it successful it adds the user ID to the 'X-Kratos-Authenticated-Identity-Id' header\nin the response.\n\nIf you call this endpoint from a server-side application, you must forward the HTTP Cookie Header to this endpoint:\n\n```js\npseudo-code example\nrouter.get('/protected-endpoint', async function (req, res) {\nconst session = await client.toSession(undefined, req.header('cookie'))\n\nconsole.log(session)\n})\n```\n\nWhen calling this endpoint from a non-browser application (e.g. mobile app) you must include the session token:\n\n```js\npseudo-code example\n...\nconst session = await client.toSession(\"the-session-token\")\n\nconsole.log(session)\n```\n\nDepending on your configuration this endpoint might return a 403 status code if the session has a lower Authenticator\nAssurance Level (AAL) than is possible for the identity. This can happen if the identity has password + webauthn\ncredentials (which would result in AAL2) but the session has only AAL1. If this error occurs, ask the user\nto sign in with the second factor or change the configuration.\n\nThis endpoint is useful for:\n\nAJAX calls. Remember to send credentials and set up CORS correctly!\nReverse proxies and API Gateways\nServer-side calls - use the `X-Session-Token` header!\n\nThis endpoint authenticates users by checking:\n\nif the `Cookie` HTTP header was set containing an Ory Kratos Session Cookie;\nif the `Authorization: bearer \u003cory-session-token\u003e` HTTP header was set with a valid Ory Kratos Session Token;\nif the `X-Session-Token` HTTP header was set with a valid Ory Kratos Session Token.\n\nIf none of these headers are set or the cooke or token are invalid, the endpoint returns a HTTP 401 status code.\n\nAs explained above, this request may fail due to several reasons. The `error.id` can be one of:\n\n`session_inactive`: No active session was found in the request (e.g. no Ory Session Cookie / Ory Session Token).\n`session_aal2_required`: An active session was found but it does not fulfil the Authenticator Assurance Level, implying that the session must (e.g.) authenticate the second factor.", diff --git a/test/e2e/package.json b/test/e2e/package.json index b55910680856..c3204c330060 100644 --- a/test/e2e/package.json +++ b/test/e2e/package.json @@ -18,12 +18,12 @@ "chrome-remote-interface": "0.31.2", "cypress": "^11.2.0", "dayjs": "^1.10.4", + "dotenv": "^16.0.3", "got": "^11.8.2", "json-schema-to-typescript": "^12.0.0", "otplib": "^12.0.1", "typescript": "^4.7.4", "wait-on": "5.3.0", - "yamljs": "^0.3.0", - "dotenv": "^16.0.3" + "yamljs": "^0.3.0" } } diff --git a/test/e2e/playwright.config.ts b/test/e2e/playwright.config.ts new file mode 100644 index 000000000000..7c0d0b5bae4b --- /dev/null +++ b/test/e2e/playwright.config.ts @@ -0,0 +1,63 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { defineConfig, devices } from "@playwright/test" +import * as dotenv from "dotenv" + +dotenv.config({ path: "playwright/playwright.env" }) + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: "./playwright/tests", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 1, + workers: 3, + reporter: process.env.CI ? [["github"], ["html"], ["list"]] : "html", + + globalSetup: "./playwright/setup/global_setup.ts", + + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + trace: process.env.CI ? "retain-on-failure" : "on", + baseURL: "http://localhost:4457", + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "Mobile Chrome", + use: { ...devices["Pixel 5"] }, + }, + ], + + webServer: [ + { + command: [ + "go run -tags sqlite,json1 . migrate sql -e --yes", + "go run -tags sqlite,json1 . serve --watch-courier --dev -c test/e2e/playwright/kratos.config.json", + ].join(" && "), + cwd: "../..", + url: "http://localhost:4433/health/ready", + reuseExistingServer: false, + env: { DSN: dbToDsn() }, + }, + ], +}) + +function dbToDsn(): string { + switch (process.env.DB) { + case "postgres": + return process.env.TEST_DATABASE_POSTGRESQL + case "cockroach": + return process.env.TEST_DATABASE_COCKROACHDB + case "mysql": + return process.env.TEST_DATABASE_MYSQL + case "sqlite": + return process.env.TEST_DATABASE_SQLITE + default: + return "memory" + } +} diff --git a/test/e2e/playwright/setup/default_config.ts b/test/e2e/playwright/setup/default_config.ts index 468613f4fd9d..2617df23ae06 100644 --- a/test/e2e/playwright/setup/default_config.ts +++ b/test/e2e/playwright/setup/default_config.ts @@ -4,7 +4,7 @@ import { OryKratosConfiguration } from "../../cypress/support/config" export const default_config: OryKratosConfiguration = { - dsn: process.env["DSN"], + dsn: "", identity: { schemas: [ { From 6aca4e0ced42fd403db13ad052ce7b38ca8ece88 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Mon, 24 Apr 2023 20:17:25 +0200 Subject: [PATCH 12/15] add kratos.config.json for playwright --- test/e2e/playwright/kratos.config.json | 130 +++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 test/e2e/playwright/kratos.config.json diff --git a/test/e2e/playwright/kratos.config.json b/test/e2e/playwright/kratos.config.json new file mode 100644 index 000000000000..831cb3f8dbd5 --- /dev/null +++ b/test/e2e/playwright/kratos.config.json @@ -0,0 +1,130 @@ +{ + "dsn": "", + "identity": { + "schemas": [ + { + "id": "default", + "url": "file://test/e2e/profiles/oidc/identity.traits.schema.json" + } + ] + }, + "serve": { + "public": { + "base_url": "http://localhost:4455/", + "cors": { + "enabled": true, + "allowed_origins": [ + "http://localhost:3000", + "http://localhost:4457" + ], + "allowed_headers": [ + "Authorization", + "Content-Type", + "X-Session-Token" + ] + } + } + }, + "log": { + "level": "trace", + "leak_sensitive_values": true + }, + "secrets": { + "cookie": [ + "PLEASE-CHANGE-ME-I-AM-VERY-INSECURE" + ], + "cipher": [ + "secret-thirty-two-character-long" + ] + }, + "selfservice": { + "default_browser_return_url": "http://localhost:4455/", + "allowed_return_urls": [ + "http://localhost:4455", + "http://localhost:4457", + "https://www.ory.sh/", + "https://example.org/", + "https://www.example.org/", + "exp://example.com/my-app", + "https://example.com/my-app" + ], + "methods": { + "link": { + "config": { + "lifespan": "1h" + } + }, + "code": { + "config": { + "lifespan": "1h" + } + }, + "oidc": { + "enabled": true, + "config": { + "providers": [ + { + "id": "hydra", + "label": "Ory", + "provider": "generic", + "client_id": "client_id", + "client_secret": "client_secret", + "issuer_url": "http://localhost:4444/", + "scope": [ + "offline" + ], + "mapper_url": "file://test/e2e/profiles/oidc/hydra.jsonnet" + } + ] + } + } + }, + "flows": { + "settings": { + "privileged_session_max_age": "5m", + "ui_url": "http://localhost:4455/settings" + }, + "logout": { + "after": { + "default_browser_return_url": "http://localhost:4455/login" + } + }, + "registration": { + "ui_url": "http://localhost:4455/registration", + "after": { + "password": { + "hooks": [ + { + "hook": "session" + } + ] + }, + "oidc": { + "hooks": [ + { + "hook": "session" + } + ] + } + } + }, + "login": { + "ui_url": "http://localhost:4455/login" + }, + "error": { + "ui_url": "http://localhost:4455/error" + }, + "verification": { + "ui_url": "http://localhost:4455/verify" + }, + "recovery": { + "ui_url": "http://localhost:4455/recovery" + } + } + }, + "courier": { + "smtp": { + "connection_uri": "smtps://test:test@localhost:1025/?skip_ssl_verify=true" + } + } +} From 071e762b55988804c90297843009cdac2eb4ef06 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Tue, 25 Apr 2023 10:16:25 +0200 Subject: [PATCH 13/15] de-paralellize playwright --- test/e2e/playwright.config.ts | 4 ++-- test/e2e/run.sh | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/test/e2e/playwright.config.ts b/test/e2e/playwright.config.ts index 7c0d0b5bae4b..422c16c23d61 100644 --- a/test/e2e/playwright.config.ts +++ b/test/e2e/playwright.config.ts @@ -11,10 +11,10 @@ dotenv.config({ path: "playwright/playwright.env" }) */ export default defineConfig({ testDir: "./playwright/tests", - fullyParallel: true, + fullyParallel: false, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 1, - workers: 3, + workers: 1, reporter: process.env.CI ? [["github"], ["html"], ["list"]] : "html", globalSetup: "./playwright/setup/global_setup.ts", diff --git a/test/e2e/run.sh b/test/e2e/run.sh index 6590da1fd97f..45bf8045c2cf 100755 --- a/test/e2e/run.sh +++ b/test/e2e/run.sh @@ -281,6 +281,7 @@ run() { (modd -f test/e2e/modd.conf >"${base}/test/e2e/kratos.e2e.log" 2>&1 &) npm run wait-on -- -v -l -t 300000 http-get://localhost:4434/health/ready \ + http-get://localhost:4444/.well-known/openid-configuration \ http-get://localhost:4455/health/ready \ http-get://localhost:4445/health/ready \ http-get://localhost:4446/ \ From 513d4aacc78dd50ee5d427bb353935f581368e08 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Tue, 25 Apr 2023 12:41:26 +0200 Subject: [PATCH 14/15] add base kratos config --- test/e2e/playwright.config.ts | 1 + .../playwright/{kratos.config.json => kratos.base-config.json} | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) rename test/e2e/playwright/{kratos.config.json => kratos.base-config.json} (99%) diff --git a/test/e2e/playwright.config.ts b/test/e2e/playwright.config.ts index 422c16c23d61..f5a8e00ccd25 100644 --- a/test/e2e/playwright.config.ts +++ b/test/e2e/playwright.config.ts @@ -36,6 +36,7 @@ export default defineConfig({ webServer: [ { command: [ + "cp test/e2e/playwright/kratos.base-config.json test/e2e/playwright/kratos.config.json", "go run -tags sqlite,json1 . migrate sql -e --yes", "go run -tags sqlite,json1 . serve --watch-courier --dev -c test/e2e/playwright/kratos.config.json", ].join(" && "), diff --git a/test/e2e/playwright/kratos.config.json b/test/e2e/playwright/kratos.base-config.json similarity index 99% rename from test/e2e/playwright/kratos.config.json rename to test/e2e/playwright/kratos.base-config.json index 831cb3f8dbd5..8486dc910d42 100644 --- a/test/e2e/playwright/kratos.config.json +++ b/test/e2e/playwright/kratos.base-config.json @@ -1,5 +1,4 @@ { - "dsn": "", "identity": { "schemas": [ { From 509ec6094dfe6a60dc627dce2f59e197db235e52 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Tue, 25 Apr 2023 13:03:14 +0200 Subject: [PATCH 15/15] address review comments --- selfservice/flow/registration/handler.go | 2 -- test/e2e/playwright/tests/app_login.spec.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/selfservice/flow/registration/handler.go b/selfservice/flow/registration/handler.go index 8ab8040fc483..281821bc36db 100644 --- a/selfservice/flow/registration/handler.go +++ b/selfservice/flow/registration/handler.go @@ -122,8 +122,6 @@ func (h *Handler) NewRegistrationFlow(w http.ResponseWriter, r *http.Request, ft } if ft == flow.TypeAPI && r.URL.Query().Get("return_session_token_exchange_code") == "true" { - // Panicing here is ok since it will return a 500 to the user, which is accurate for when - // we can't generate a random string. e, err := h.d.SessionTokenExchangePersister().CreateSessionTokenExchanger(r.Context(), f.ID) if err != nil { return nil, errors.WithStack(herodot.ErrInternalServerError.WithWrap(err)) diff --git a/test/e2e/playwright/tests/app_login.spec.ts b/test/e2e/playwright/tests/app_login.spec.ts index abe7ead3115f..600a49a48ce1 100644 --- a/test/e2e/playwright/tests/app_login.spec.ts +++ b/test/e2e/playwright/tests/app_login.spec.ts @@ -28,7 +28,7 @@ async function testRegistrationOrLogin(page: Page, username: string) { await page.getByText(/sign (up|in) with ory/i).click() const popup = await popupPromise - await performOidcLogin(popup, "registration@example.com") + await performOidcLogin(popup, username) await page.waitForURL("Home") expect(popup.isClosed()).toBeTruthy()