diff --git a/cmd/apiserver/main.go b/cmd/apiserver/main.go index 4fe5e8530..a59895da0 100644 --- a/cmd/apiserver/main.go +++ b/cmd/apiserver/main.go @@ -189,13 +189,6 @@ func main() { Usage: "OTLP endpoint for trace data", Sources: cli.EnvVars("NEXAPI_TRACE_ENDPOINT_OTLP"), }, - - &cli.StringFlag{ - Name: "redirect-url", - Usage: "Redirect URL. This is the URL of the SPA.", - Value: "https://example.com", - Sources: cli.EnvVars("NEXAPI_REDIRECT_URL"), - }, &cli.StringSliceFlag{ Name: "scopes", Usage: "Additional OAUTH2 scopes", @@ -344,6 +337,12 @@ func main() { log.Fatal(err) } + api.URL = command.String("url") + api.URLParsed, err = url.Parse(api.URL) + if err != nil { + log.Fatal(fmt.Errorf("invalid url: %w", err)) + } + smtpServer := email.SmtpServer{ HostPort: command.String("smtp-host-port"), User: command.String("smtp-user"), @@ -368,7 +367,7 @@ func main() { command.Bool("insecure-tls"), command.String("oidc-client-id-web"), command.String("oidc-client-secret-web"), - command.String("redirect-url"), + fmt.Sprintf("%s/web/login/end", api.URL), scopes, command.String("domain"), command.StringSlice("origins"), @@ -403,11 +402,6 @@ func main() { if err != nil { log.Fatal(fmt.Errorf("invalid tls-key: %w", err)) } - api.URL = command.String("url") - api.URLParsed, err = url.Parse(api.URL) - if err != nil { - log.Fatal(fmt.Errorf("invalid url: %w", err)) - } router, err := routers.NewAPIRouter(ctx, routers.APIRouterOptions{ Logger: logger.Sugar(), diff --git a/deploy/nexodus/base/apiserver/deployment.yaml b/deploy/nexodus/base/apiserver/deployment.yaml index d0f5c6e65..7057e1485 100644 --- a/deploy/nexodus/base/apiserver/deployment.yaml +++ b/deploy/nexodus/base/apiserver/deployment.yaml @@ -103,11 +103,6 @@ spec: configMapKeyRef: name: apiserver key: NEXAPI_FFLAG_SECURITY_GROUPS - - name: NEXAPI_REDIRECT_URL - valueFrom: - configMapKeyRef: - name: apiserver - key: NEXAPI_REDIRECT_URL - name: NEXAPI_ORIGINS valueFrom: configMapKeyRef: diff --git a/deploy/nexodus/base/apiserver/kustomization.yaml b/deploy/nexodus/base/apiserver/kustomization.yaml index 07f3954dc..8ea36281c 100644 --- a/deploy/nexodus/base/apiserver/kustomization.yaml +++ b/deploy/nexodus/base/apiserver/kustomization.yaml @@ -13,7 +13,6 @@ configMapGenerator: - NEXAPI_DB_SSLMODE=require - NEXAPI_DOMAIN=api.try.nexodus.127.0.0.1.nip.io - NEXAPI_URL=https://api.try.nexodus.127.0.0.1.nip.io - - NEXAPI_REDIRECT_URL=https://try.nexodus.127.0.0.1.nip.io/#/login - NEXAPI_ORIGINS=https://try.nexodus.127.0.0.1.nip.io - NEXAPI_SCOPES=read:organizations,write:organizations,read:users,write:users,read:devices,write:devices - NEXAPI_REDIS_SERVER=redis:6379 diff --git a/deploy/nexodus/base/auth/deployment.yaml b/deploy/nexodus/base/auth/deployment.yaml index 9aa3955e8..2ad685a92 100644 --- a/deploy/nexodus/base/auth/deployment.yaml +++ b/deploy/nexodus/base/auth/deployment.yaml @@ -83,6 +83,11 @@ spec: configMapKeyRef: name: auth-config key: frontend-url + - name: REDIRECT_URL + valueFrom: + configMapKeyRef: + name: auth-config + key: redirect-url - name: GOOGLE_CLIENT_ID valueFrom: secretKeyRef: diff --git a/deploy/nexodus/base/auth/files/nexodus.json b/deploy/nexodus/base/auth/files/nexodus.json index 777dcbdce..223c398b6 100644 --- a/deploy/nexodus/base/auth/files/nexodus.json +++ b/deploy/nexodus/base/auth/files/nexodus.json @@ -742,7 +742,7 @@ "clientAuthenticatorType": "client-secret", "secret": "${WEB_CLIENT_SECRET}", "redirectUris": [ - "${FRONTEND_URL}/*" + "${REDIRECT_URL}/*" ], "webOrigins": [ "+" diff --git a/deploy/nexodus/base/auth/kustomization.yaml b/deploy/nexodus/base/auth/kustomization.yaml index 229275f56..7591e00fb 100644 --- a/deploy/nexodus/base/auth/kustomization.yaml +++ b/deploy/nexodus/base/auth/kustomization.yaml @@ -9,6 +9,7 @@ configMapGenerator: - literals: - hostname=auth.try.nexodus.127.0.0.1.nip.io - frontend-url=https://try.nexodus.127.0.0.1.nip.io + - redirect-url=https://api.try.nexodus.127.0.0.1.nip.io/web name: auth-config - files: - files/nexodus.json diff --git a/deploy/nexodus/overlays/playground/files/nexodus.json b/deploy/nexodus/overlays/playground/files/nexodus.json index 8d1d3395f..260469b4b 100644 --- a/deploy/nexodus/overlays/playground/files/nexodus.json +++ b/deploy/nexodus/overlays/playground/files/nexodus.json @@ -742,7 +742,7 @@ "clientAuthenticatorType": "client-secret", "secret": "${WEB_CLIENT_SECRET}", "redirectUris": [ - "${FRONTEND_URL}/*" + "${REDIRECT_URL}/*" ], "webOrigins": [ "+" diff --git a/deploy/nexodus/overlays/playground/kustomization.yaml b/deploy/nexodus/overlays/playground/kustomization.yaml index 411c3ce41..d16330b1e 100644 --- a/deploy/nexodus/overlays/playground/kustomization.yaml +++ b/deploy/nexodus/overlays/playground/kustomization.yaml @@ -11,6 +11,7 @@ configMapGenerator: literals: - hostname=auth.playground.nexodus.io - frontend-url=https://playground.nexodus.io + - redirect-url=https://api.playground.nexodus.io/web - behavior: replace name: realm files: @@ -30,7 +31,6 @@ configMapGenerator: - NEXAPI_URL=https://api.playground.nexodus.io - NEXAPI_OIDC_URL=https://auth.playground.nexodus.io/realms/nexodus - NEXAPI_DOMAIN=api.playground.nexodus.io - - NEXAPI_REDIRECT_URL=https://playground.nexodus.io/#/login - NEXAPI_ORIGINS=https://playground.nexodus.io - NEXAPI_ENVIRONMENT=qa - NEXAPI_FFLAG_DEVICES=false diff --git a/deploy/nexodus/overlays/prod/files/nexodus.json b/deploy/nexodus/overlays/prod/files/nexodus.json index 8d1d3395f..260469b4b 100644 --- a/deploy/nexodus/overlays/prod/files/nexodus.json +++ b/deploy/nexodus/overlays/prod/files/nexodus.json @@ -742,7 +742,7 @@ "clientAuthenticatorType": "client-secret", "secret": "${WEB_CLIENT_SECRET}", "redirectUris": [ - "${FRONTEND_URL}/*" + "${REDIRECT_URL}/*" ], "webOrigins": [ "+" diff --git a/deploy/nexodus/overlays/prod/kustomization.yaml b/deploy/nexodus/overlays/prod/kustomization.yaml index 3415a596d..b8c4c3a31 100644 --- a/deploy/nexodus/overlays/prod/kustomization.yaml +++ b/deploy/nexodus/overlays/prod/kustomization.yaml @@ -10,6 +10,7 @@ configMapGenerator: literals: - hostname=auth.try.nexodus.io - frontend-url=https://try.nexodus.io + - redirect-url=https://api.try.nexodus.io/web name: auth-config - behavior: replace files: @@ -29,7 +30,6 @@ configMapGenerator: - NEXAPI_URL=https://api.try.nexodus.io - NEXAPI_OIDC_URL=https://auth.try.nexodus.io/realms/nexodus - NEXAPI_DOMAIN=api.try.nexodus.io - - NEXAPI_REDIRECT_URL=https://try.nexodus.io/#/login - NEXAPI_ORIGINS=https://try.nexodus.io - NEXAPI_ENVIRONMENT=production - NEXAPI_FFLAG_SITES=false diff --git a/deploy/nexodus/overlays/qa/files/nexodus.json b/deploy/nexodus/overlays/qa/files/nexodus.json index 8d1d3395f..260469b4b 100644 --- a/deploy/nexodus/overlays/qa/files/nexodus.json +++ b/deploy/nexodus/overlays/qa/files/nexodus.json @@ -742,7 +742,7 @@ "clientAuthenticatorType": "client-secret", "secret": "${WEB_CLIENT_SECRET}", "redirectUris": [ - "${FRONTEND_URL}/*" + "${REDIRECT_URL}/*" ], "webOrigins": [ "+" diff --git a/deploy/nexodus/overlays/qa/kustomization.yaml b/deploy/nexodus/overlays/qa/kustomization.yaml index e297de08a..2511f502d 100644 --- a/deploy/nexodus/overlays/qa/kustomization.yaml +++ b/deploy/nexodus/overlays/qa/kustomization.yaml @@ -14,6 +14,7 @@ configMapGenerator: literals: - hostname=auth.qa.nexodus.io - frontend-url=https://qa.nexodus.io + - redirect-url=https://api.qa.nexodus.io/web name: auth-config - behavior: merge literals: @@ -29,7 +30,6 @@ configMapGenerator: - NEXAPI_URL=https://api.qa.nexodus.io - NEXAPI_OIDC_URL=https://auth.qa.nexodus.io/realms/nexodus - NEXAPI_DOMAIN=api.qa.nexodus.io - - NEXAPI_REDIRECT_URL=https://qa.nexodus.io/#/login - NEXAPI_ORIGINS=https://qa.nexodus.io - NEXAPI_ENVIRONMENT=qa - NEXAPI_DEBUG=0 diff --git a/internal/api/public/.openapi-generator/FILES b/internal/api/public/.openapi-generator/FILES index 50bc6900b..e6562c6e7 100644 --- a/internal/api/public/.openapi-generator/FILES +++ b/internal/api/public/.openapi-generator/FILES @@ -29,14 +29,8 @@ model_models_endpoint.go model_models_internal_server_error.go model_models_invitation.go model_models_key_usage.go -model_models_login_end_request.go -model_models_login_end_response.go -model_models_login_start_response.go -model_models_logout_response.go model_models_not_allowed_error.go model_models_organization.go -model_models_refresh_token_request.go -model_models_refresh_token_response.go model_models_reg_key.go model_models_security_group.go model_models_security_rule.go diff --git a/internal/api/public/api_auth.go b/internal/api/public/api_auth.go index b177a3f31..bd940f355 100644 --- a/internal/api/public/api_auth.go +++ b/internal/api/public/api_auth.go @@ -434,9 +434,16 @@ func (a *AuthApiService) DeviceStartExecute(r ApiDeviceStartRequest) (*ModelsDev type ApiLogoutRequest struct { ctx context.Context ApiService *AuthApiService + redirect *string } -func (r ApiLogoutRequest) Execute() (*ModelsLogoutResponse, *http.Response, error) { +// URL to redirect to after logout +func (r ApiLogoutRequest) Redirect(redirect string) ApiLogoutRequest { + r.redirect = &redirect + return r +} + +func (r ApiLogoutRequest) Execute() (*http.Response, error) { return r.ApiService.LogoutExecute(r) } @@ -456,19 +463,16 @@ func (a *AuthApiService) Logout(ctx context.Context) ApiLogoutRequest { } // Execute executes the request -// -// @return ModelsLogoutResponse -func (a *AuthApiService) LogoutExecute(r ApiLogoutRequest) (*ModelsLogoutResponse, *http.Response, error) { +func (a *AuthApiService) LogoutExecute(r ApiLogoutRequest) (*http.Response, error) { var ( - localVarHTTPMethod = http.MethodPost - localVarPostBody interface{} - formFiles []formFile - localVarReturnValue *ModelsLogoutResponse + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile ) localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "AuthApiService.Logout") if err != nil { - return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + return nil, &GenericOpenAPIError{error: err.Error()} } localVarPath := localBasePath + "/web/logout" @@ -476,7 +480,11 @@ func (a *AuthApiService) LogoutExecute(r ApiLogoutRequest) (*ModelsLogoutRespons localVarHeaderParams := make(map[string]string) localVarQueryParams := url.Values{} localVarFormParams := url.Values{} + if r.redirect == nil { + return nil, reportError("redirect is required and must be specified") + } + parameterAddToHeaderOrQuery(localVarQueryParams, "redirect", r.redirect, "") // to determine the Content-Type header localVarHTTPContentTypes := []string{} @@ -496,19 +504,19 @@ func (a *AuthApiService) LogoutExecute(r ApiLogoutRequest) (*ModelsLogoutRespons } req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) if err != nil { - return localVarReturnValue, nil, err + return nil, err } localVarHTTPResponse, err := a.client.callAPI(req) if err != nil || localVarHTTPResponse == nil { - return localVarReturnValue, localVarHTTPResponse, err + return 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 + return localVarHTTPResponse, err } if localVarHTTPResponse.StatusCode >= 300 { @@ -516,34 +524,28 @@ func (a *AuthApiService) LogoutExecute(r ApiLogoutRequest) (*ModelsLogoutRespons body: localVarBody, error: localVarHTTPResponse.Status, } - 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(), + if localVarHTTPResponse.StatusCode == 302 { + var v string + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v } - return localVarReturnValue, localVarHTTPResponse, newErr + return localVarHTTPResponse, newErr } - return localVarReturnValue, localVarHTTPResponse, nil + return localVarHTTPResponse, nil } type ApiRefreshRequest struct { ctx context.Context ApiService *AuthApiService - data *ModelsRefreshTokenRequest } -// End Login -func (r ApiRefreshRequest) Data(data ModelsRefreshTokenRequest) ApiRefreshRequest { - r.data = &data - return r -} - -func (r ApiRefreshRequest) Execute() (*ModelsRefreshTokenResponse, *http.Response, error) { +func (r ApiRefreshRequest) Execute() (*http.Response, error) { return r.ApiService.RefreshExecute(r) } @@ -563,19 +565,16 @@ func (a *AuthApiService) Refresh(ctx context.Context) ApiRefreshRequest { } // Execute executes the request -// -// @return ModelsRefreshTokenResponse -func (a *AuthApiService) RefreshExecute(r ApiRefreshRequest) (*ModelsRefreshTokenResponse, *http.Response, error) { +func (a *AuthApiService) RefreshExecute(r ApiRefreshRequest) (*http.Response, error) { var ( - localVarHTTPMethod = http.MethodPost - localVarPostBody interface{} - formFiles []formFile - localVarReturnValue *ModelsRefreshTokenResponse + localVarHTTPMethod = http.MethodPost + localVarPostBody interface{} + formFiles []formFile ) localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "AuthApiService.Refresh") if err != nil { - return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + return nil, &GenericOpenAPIError{error: err.Error()} } localVarPath := localBasePath + "/web/refresh" @@ -583,12 +582,9 @@ func (a *AuthApiService) RefreshExecute(r ApiRefreshRequest) (*ModelsRefreshToke localVarHeaderParams := make(map[string]string) localVarQueryParams := url.Values{} localVarFormParams := url.Values{} - if r.data == nil { - return localVarReturnValue, nil, reportError("data is required and must be specified") - } // to determine the Content-Type header - localVarHTTPContentTypes := []string{"application/json"} + localVarHTTPContentTypes := []string{} // set Content-Type header localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) @@ -597,30 +593,28 @@ func (a *AuthApiService) RefreshExecute(r ApiRefreshRequest) (*ModelsRefreshToke } // to determine the Accept header - localVarHTTPHeaderAccepts := []string{"application/json"} + localVarHTTPHeaderAccepts := []string{} // set Accept header localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) if localVarHTTPHeaderAccept != "" { localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept } - // body params - localVarPostBody = r.data req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) if err != nil { - return localVarReturnValue, nil, err + return nil, err } localVarHTTPResponse, err := a.client.callAPI(req) if err != nil || localVarHTTPResponse == nil { - return localVarReturnValue, localVarHTTPResponse, err + return 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 + return localVarHTTPResponse, err } if localVarHTTPResponse.StatusCode >= 300 { @@ -628,19 +622,10 @@ func (a *AuthApiService) RefreshExecute(r ApiRefreshRequest) (*ModelsRefreshToke body: localVarBody, error: localVarHTTPResponse.Status, } - return localVarReturnValue, localVarHTTPResponse, newErr + return 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 + return localVarHTTPResponse, nil } type ApiUserInfoRequest struct { @@ -746,16 +731,30 @@ func (a *AuthApiService) UserInfoExecute(r ApiUserInfoRequest) (*ModelsUserInfoR type ApiWebEndRequest struct { ctx context.Context ApiService *AuthApiService - data *ModelsLoginEndRequest + code *string + state *string + error_ *string +} + +// oauth2 code from authorization server +func (r ApiWebEndRequest) Code(code string) ApiWebEndRequest { + r.code = &code + return r +} + +// state value from the login start request +func (r ApiWebEndRequest) State(state string) ApiWebEndRequest { + r.state = &state + return r } -// End Login -func (r ApiWebEndRequest) Data(data ModelsLoginEndRequest) ApiWebEndRequest { - r.data = &data +// error message if login failed +func (r ApiWebEndRequest) Error_(error_ string) ApiWebEndRequest { + r.error_ = &error_ return r } -func (r ApiWebEndRequest) Execute() (*ModelsLoginEndResponse, *http.Response, error) { +func (r ApiWebEndRequest) Execute() (*http.Response, error) { return r.ApiService.WebEndExecute(r) } @@ -775,19 +774,16 @@ func (a *AuthApiService) WebEnd(ctx context.Context) ApiWebEndRequest { } // Execute executes the request -// -// @return ModelsLoginEndResponse -func (a *AuthApiService) WebEndExecute(r ApiWebEndRequest) (*ModelsLoginEndResponse, *http.Response, error) { +func (a *AuthApiService) WebEndExecute(r ApiWebEndRequest) (*http.Response, error) { var ( - localVarHTTPMethod = http.MethodPost - localVarPostBody interface{} - formFiles []formFile - localVarReturnValue *ModelsLoginEndResponse + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile ) localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "AuthApiService.WebEnd") if err != nil { - return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + return nil, &GenericOpenAPIError{error: err.Error()} } localVarPath := localBasePath + "/web/login/end" @@ -795,10 +791,19 @@ func (a *AuthApiService) WebEndExecute(r ApiWebEndRequest) (*ModelsLoginEndRespo localVarHeaderParams := make(map[string]string) localVarQueryParams := url.Values{} localVarFormParams := url.Values{} - if r.data == nil { - return localVarReturnValue, nil, reportError("data is required and must be specified") + if r.code == nil { + return nil, reportError("code is required and must be specified") + } + if r.state == nil { + return nil, reportError("state is required and must be specified") + } + if r.error_ == nil { + return nil, reportError("error_ is required and must be specified") } + parameterAddToHeaderOrQuery(localVarQueryParams, "code", r.code, "") + parameterAddToHeaderOrQuery(localVarQueryParams, "state", r.state, "") + parameterAddToHeaderOrQuery(localVarQueryParams, "error", r.error_, "") // to determine the Content-Type header localVarHTTPContentTypes := []string{} @@ -816,23 +821,21 @@ func (a *AuthApiService) WebEndExecute(r ApiWebEndRequest) (*ModelsLoginEndRespo if localVarHTTPHeaderAccept != "" { localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept } - // body params - localVarPostBody = r.data req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) if err != nil { - return localVarReturnValue, nil, err + return nil, err } localVarHTTPResponse, err := a.client.callAPI(req) if err != nil || localVarHTTPResponse == nil { - return localVarReturnValue, localVarHTTPResponse, err + return 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 + return localVarHTTPResponse, err } if localVarHTTPResponse.StatusCode >= 300 { @@ -840,27 +843,42 @@ func (a *AuthApiService) WebEndExecute(r ApiWebEndRequest) (*ModelsLoginEndRespo body: localVarBody, error: localVarHTTPResponse.Status, } - 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(), + if localVarHTTPResponse.StatusCode == 302 { + var v string + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v } - return localVarReturnValue, localVarHTTPResponse, newErr + return localVarHTTPResponse, newErr } - return localVarReturnValue, localVarHTTPResponse, nil + return localVarHTTPResponse, nil } type ApiWebStartRequest struct { ctx context.Context ApiService *AuthApiService + redirect *string + failure *string +} + +// URL to redirect to if login succeeds +func (r ApiWebStartRequest) Redirect(redirect string) ApiWebStartRequest { + r.redirect = &redirect + return r } -func (r ApiWebStartRequest) Execute() (*ModelsLoginStartResponse, *http.Response, error) { +// URL to redirect to if login fails (optional) +func (r ApiWebStartRequest) Failure(failure string) ApiWebStartRequest { + r.failure = &failure + return r +} + +func (r ApiWebStartRequest) Execute() (*http.Response, error) { return r.ApiService.WebStartExecute(r) } @@ -880,19 +898,16 @@ func (a *AuthApiService) WebStart(ctx context.Context) ApiWebStartRequest { } // Execute executes the request -// -// @return ModelsLoginStartResponse -func (a *AuthApiService) WebStartExecute(r ApiWebStartRequest) (*ModelsLoginStartResponse, *http.Response, error) { +func (a *AuthApiService) WebStartExecute(r ApiWebStartRequest) (*http.Response, error) { var ( - localVarHTTPMethod = http.MethodPost - localVarPostBody interface{} - formFiles []formFile - localVarReturnValue *ModelsLoginStartResponse + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile ) localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "AuthApiService.WebStart") if err != nil { - return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + return nil, &GenericOpenAPIError{error: err.Error()} } localVarPath := localBasePath + "/web/login/start" @@ -900,7 +915,14 @@ func (a *AuthApiService) WebStartExecute(r ApiWebStartRequest) (*ModelsLoginStar localVarHeaderParams := make(map[string]string) localVarQueryParams := url.Values{} localVarFormParams := url.Values{} + if r.redirect == nil { + return nil, reportError("redirect is required and must be specified") + } + parameterAddToHeaderOrQuery(localVarQueryParams, "redirect", r.redirect, "") + if r.failure != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "failure", r.failure, "") + } // to determine the Content-Type header localVarHTTPContentTypes := []string{} @@ -920,19 +942,19 @@ func (a *AuthApiService) WebStartExecute(r ApiWebStartRequest) (*ModelsLoginStar } req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) if err != nil { - return localVarReturnValue, nil, err + return nil, err } localVarHTTPResponse, err := a.client.callAPI(req) if err != nil || localVarHTTPResponse == nil { - return localVarReturnValue, localVarHTTPResponse, err + return 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 + return localVarHTTPResponse, err } if localVarHTTPResponse.StatusCode >= 300 { @@ -940,17 +962,18 @@ func (a *AuthApiService) WebStartExecute(r ApiWebStartRequest) (*ModelsLoginStar body: localVarBody, error: localVarHTTPResponse.Status, } - 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(), + if localVarHTTPResponse.StatusCode == 302 { + var v string + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v } - return localVarReturnValue, localVarHTTPResponse, newErr + return localVarHTTPResponse, newErr } - return localVarReturnValue, localVarHTTPResponse, nil + return localVarHTTPResponse, nil } diff --git a/internal/api/public/model_models_login_end_request.go b/internal/api/public/model_models_login_end_request.go deleted file mode 100644 index 56f8d3728..000000000 --- a/internal/api/public/model_models_login_end_request.go +++ /dev/null @@ -1,16 +0,0 @@ -/* -Nexodus API - -This is the Nexodus API Server. - -API version: 1.0 -*/ - -// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. - -package public - -// ModelsLoginEndRequest struct for ModelsLoginEndRequest -type ModelsLoginEndRequest struct { - RequestUrl string `json:"request_url,omitempty"` -} diff --git a/internal/api/public/model_models_login_end_response.go b/internal/api/public/model_models_login_end_response.go deleted file mode 100644 index ec528041c..000000000 --- a/internal/api/public/model_models_login_end_response.go +++ /dev/null @@ -1,19 +0,0 @@ -/* -Nexodus API - -This is the Nexodus API Server. - -API version: 1.0 -*/ - -// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. - -package public - -// ModelsLoginEndResponse struct for ModelsLoginEndResponse -type ModelsLoginEndResponse struct { - AccessToken string `json:"access_token,omitempty"` - Handled bool `json:"handled,omitempty"` - LoggedIn bool `json:"logged_in,omitempty"` - RefreshToken string `json:"refresh_token,omitempty"` -} diff --git a/internal/api/public/model_models_login_start_response.go b/internal/api/public/model_models_login_start_response.go deleted file mode 100644 index 59dca84e8..000000000 --- a/internal/api/public/model_models_login_start_response.go +++ /dev/null @@ -1,16 +0,0 @@ -/* -Nexodus API - -This is the Nexodus API Server. - -API version: 1.0 -*/ - -// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. - -package public - -// ModelsLoginStartResponse struct for ModelsLoginStartResponse -type ModelsLoginStartResponse struct { - AuthorizationRequestUrl string `json:"authorization_request_url,omitempty"` -} diff --git a/internal/api/public/model_models_logout_response.go b/internal/api/public/model_models_logout_response.go deleted file mode 100644 index 5f6a58638..000000000 --- a/internal/api/public/model_models_logout_response.go +++ /dev/null @@ -1,16 +0,0 @@ -/* -Nexodus API - -This is the Nexodus API Server. - -API version: 1.0 -*/ - -// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. - -package public - -// ModelsLogoutResponse struct for ModelsLogoutResponse -type ModelsLogoutResponse struct { - LogoutUrl string `json:"logout_url,omitempty"` -} diff --git a/internal/api/public/model_models_refresh_token_request.go b/internal/api/public/model_models_refresh_token_request.go deleted file mode 100644 index 765237a10..000000000 --- a/internal/api/public/model_models_refresh_token_request.go +++ /dev/null @@ -1,16 +0,0 @@ -/* -Nexodus API - -This is the Nexodus API Server. - -API version: 1.0 -*/ - -// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. - -package public - -// ModelsRefreshTokenRequest struct for ModelsRefreshTokenRequest -type ModelsRefreshTokenRequest struct { - RefreshToken string `json:"refresh_token,omitempty"` -} diff --git a/internal/api/public/model_models_refresh_token_response.go b/internal/api/public/model_models_refresh_token_response.go deleted file mode 100644 index 7e4c4a5e4..000000000 --- a/internal/api/public/model_models_refresh_token_response.go +++ /dev/null @@ -1,17 +0,0 @@ -/* -Nexodus API - -This is the Nexodus API Server. - -API version: 1.0 -*/ - -// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. - -package public - -// ModelsRefreshTokenResponse struct for ModelsRefreshTokenResponse -type ModelsRefreshTokenResponse struct { - AccessToken string `json:"access_token,omitempty"` - RefreshToken string `json:"refresh_token,omitempty"` -} diff --git a/internal/api/public/model_models_update_device.go b/internal/api/public/model_models_update_device.go index 93d383fc1..4b7dcedd8 100644 --- a/internal/api/public/model_models_update_device.go +++ b/internal/api/public/model_models_update_device.go @@ -15,8 +15,8 @@ type ModelsUpdateDevice struct { AdvertiseCidrs []string `json:"advertise_cidrs,omitempty"` Endpoints []ModelsEndpoint `json:"endpoints,omitempty"` Hostname string `json:"hostname,omitempty"` - Revision int32 `json:"revision,omitempty"` Relay bool `json:"relay,omitempty"` + Revision int32 `json:"revision,omitempty"` SecurityGroupId string `json:"security_group_id,omitempty"` SymmetricNat bool `json:"symmetric_nat,omitempty"` VpcId string `json:"vpc_id,omitempty"` diff --git a/internal/docs/docs.go b/internal/docs/docs.go index 0a0caf00c..196edbe1c 100644 --- a/internal/docs/docs.go +++ b/internal/docs/docs.go @@ -2991,7 +2991,7 @@ const docTemplate = `{ } }, "/web/login/end": { - "post": { + "get": { "description": "Handles the callback from the OAuth2/OpenID provider and verifies the tokens.", "produces": [ "application/json" @@ -3003,27 +3003,39 @@ const docTemplate = `{ "operationId": "WebEnd", "parameters": [ { - "description": "End Login", - "name": "data", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.LoginEndRequest" - } + "type": "string", + "description": "oauth2 code from authorization server", + "name": "code", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "state value from the login start request", + "name": "state", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "error message if login failed", + "name": "error", + "in": "query", + "required": true } ], "responses": { - "200": { - "description": "OK", + "302": { + "description": "Redirects to the URLs specified in the login start request", "schema": { - "$ref": "#/definitions/models.LoginEndResponse" + "type": "string" } } } } }, "/web/login/start": { - "post": { + "get": { "description": "Generates state and nonce, then redirects the user to the OAuth2 authorization URL.", "produces": [ "application/json" @@ -3033,18 +3045,33 @@ const docTemplate = `{ ], "summary": "Initiates OIDC Web Login", "operationId": "WebStart", + "parameters": [ + { + "type": "string", + "description": "URL to redirect to if login succeeds", + "name": "redirect", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "URL to redirect to if login fails (optional)", + "name": "failure", + "in": "query" + } + ], "responses": { - "200": { - "description": "OK", + "302": { + "description": "Redirects to the OAuth2 authorization URL", "schema": { - "$ref": "#/definitions/models.LoginStartResponse" + "type": "string" } } } } }, "/web/logout": { - "post": { + "get": { "description": "Provides the URL to initiate the logout process for the current user.", "consumes": [ "application/json" @@ -3057,11 +3084,20 @@ const docTemplate = `{ ], "summary": "Generate Logout URL", "operationId": "Logout", + "parameters": [ + { + "type": "string", + "description": "URL to redirect to after logout", + "name": "redirect", + "in": "query", + "required": true + } + ], "responses": { - "200": { - "description": "OK", + "302": { + "description": "Redirects to the OAuth2 logout URL", "schema": { - "$ref": "#/definitions/models.LogoutResponse" + "type": "string" } } } @@ -3081,23 +3117,9 @@ const docTemplate = `{ ], "summary": "Refresh Access Token", "operationId": "Refresh", - "parameters": [ - { - "description": "End Login", - "name": "data", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.RefreshTokenRequest" - } - } - ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/models.RefreshTokenResponse" - } + "204": { + "description": "No Content" } } } @@ -3582,47 +3604,6 @@ const docTemplate = `{ "UsageNetscapeSGC" ] }, - "models.LoginEndRequest": { - "type": "object", - "properties": { - "request_url": { - "type": "string" - } - } - }, - "models.LoginEndResponse": { - "type": "object", - "properties": { - "access_token": { - "type": "string" - }, - "handled": { - "type": "boolean" - }, - "logged_in": { - "type": "boolean" - }, - "refresh_token": { - "type": "string" - } - } - }, - "models.LoginStartResponse": { - "type": "object", - "properties": { - "authorization_request_url": { - "type": "string" - } - } - }, - "models.LogoutResponse": { - "type": "object", - "properties": { - "logout_url": { - "type": "string" - } - } - }, "models.NotAllowedError": { "type": "object", "properties": { @@ -3656,25 +3637,6 @@ const docTemplate = `{ } } }, - "models.RefreshTokenRequest": { - "type": "object", - "properties": { - "refresh_token": { - "type": "string" - } - } - }, - "models.RefreshTokenResponse": { - "type": "object", - "properties": { - "access_token": { - "type": "string" - }, - "refresh_token": { - "type": "string" - } - } - }, "models.RegKey": { "type": "object", "properties": { diff --git a/internal/docs/swagger.json b/internal/docs/swagger.json index bee41f6eb..135c1cf22 100644 --- a/internal/docs/swagger.json +++ b/internal/docs/swagger.json @@ -2984,7 +2984,7 @@ } }, "/web/login/end": { - "post": { + "get": { "description": "Handles the callback from the OAuth2/OpenID provider and verifies the tokens.", "produces": [ "application/json" @@ -2996,27 +2996,39 @@ "operationId": "WebEnd", "parameters": [ { - "description": "End Login", - "name": "data", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.LoginEndRequest" - } + "type": "string", + "description": "oauth2 code from authorization server", + "name": "code", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "state value from the login start request", + "name": "state", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "error message if login failed", + "name": "error", + "in": "query", + "required": true } ], "responses": { - "200": { - "description": "OK", + "302": { + "description": "Redirects to the URLs specified in the login start request", "schema": { - "$ref": "#/definitions/models.LoginEndResponse" + "type": "string" } } } } }, "/web/login/start": { - "post": { + "get": { "description": "Generates state and nonce, then redirects the user to the OAuth2 authorization URL.", "produces": [ "application/json" @@ -3026,18 +3038,33 @@ ], "summary": "Initiates OIDC Web Login", "operationId": "WebStart", + "parameters": [ + { + "type": "string", + "description": "URL to redirect to if login succeeds", + "name": "redirect", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "URL to redirect to if login fails (optional)", + "name": "failure", + "in": "query" + } + ], "responses": { - "200": { - "description": "OK", + "302": { + "description": "Redirects to the OAuth2 authorization URL", "schema": { - "$ref": "#/definitions/models.LoginStartResponse" + "type": "string" } } } } }, "/web/logout": { - "post": { + "get": { "description": "Provides the URL to initiate the logout process for the current user.", "consumes": [ "application/json" @@ -3050,11 +3077,20 @@ ], "summary": "Generate Logout URL", "operationId": "Logout", + "parameters": [ + { + "type": "string", + "description": "URL to redirect to after logout", + "name": "redirect", + "in": "query", + "required": true + } + ], "responses": { - "200": { - "description": "OK", + "302": { + "description": "Redirects to the OAuth2 logout URL", "schema": { - "$ref": "#/definitions/models.LogoutResponse" + "type": "string" } } } @@ -3074,23 +3110,9 @@ ], "summary": "Refresh Access Token", "operationId": "Refresh", - "parameters": [ - { - "description": "End Login", - "name": "data", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.RefreshTokenRequest" - } - } - ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/models.RefreshTokenResponse" - } + "204": { + "description": "No Content" } } } @@ -3575,47 +3597,6 @@ "UsageNetscapeSGC" ] }, - "models.LoginEndRequest": { - "type": "object", - "properties": { - "request_url": { - "type": "string" - } - } - }, - "models.LoginEndResponse": { - "type": "object", - "properties": { - "access_token": { - "type": "string" - }, - "handled": { - "type": "boolean" - }, - "logged_in": { - "type": "boolean" - }, - "refresh_token": { - "type": "string" - } - } - }, - "models.LoginStartResponse": { - "type": "object", - "properties": { - "authorization_request_url": { - "type": "string" - } - } - }, - "models.LogoutResponse": { - "type": "object", - "properties": { - "logout_url": { - "type": "string" - } - } - }, "models.NotAllowedError": { "type": "object", "properties": { @@ -3649,25 +3630,6 @@ } } }, - "models.RefreshTokenRequest": { - "type": "object", - "properties": { - "refresh_token": { - "type": "string" - } - } - }, - "models.RefreshTokenResponse": { - "type": "object", - "properties": { - "access_token": { - "type": "string" - }, - "refresh_token": { - "type": "string" - } - } - }, "models.RegKey": { "type": "object", "properties": { diff --git a/internal/docs/swagger.yaml b/internal/docs/swagger.yaml index 075869079..97de3d58d 100644 --- a/internal/docs/swagger.yaml +++ b/internal/docs/swagger.yaml @@ -375,32 +375,6 @@ definitions: - UsageOCSPSigning - UsageMicrosoftSGC - UsageNetscapeSGC - models.LoginEndRequest: - properties: - request_url: - type: string - type: object - models.LoginEndResponse: - properties: - access_token: - type: string - handled: - type: boolean - logged_in: - type: boolean - refresh_token: - type: string - type: object - models.LoginStartResponse: - properties: - authorization_request_url: - type: string - type: object - models.LogoutResponse: - properties: - logout_url: - type: string - type: object models.NotAllowedError: properties: error: @@ -424,18 +398,6 @@ definitions: example: aa22666c-0f57-45cb-a449-16efecc04f2e type: string type: object - models.RefreshTokenRequest: - properties: - refresh_token: - type: string - type: object - models.RefreshTokenResponse: - properties: - access_token: - type: string - refresh_token: - type: string - type: object models.RegKey: properties: bearer_token: @@ -2698,56 +2660,81 @@ paths: tags: - Auth /web/login/end: - post: + get: description: Handles the callback from the OAuth2/OpenID provider and verifies the tokens. operationId: WebEnd parameters: - - description: End Login - in: body - name: data + - description: oauth2 code from authorization server + in: query + name: code required: true - schema: - $ref: '#/definitions/models.LoginEndRequest' + type: string + - description: state value from the login start request + in: query + name: state + required: true + type: string + - description: error message if login failed + in: query + name: error + required: true + type: string produces: - application/json responses: - "200": - description: OK + "302": + description: Redirects to the URLs specified in the login start request schema: - $ref: '#/definitions/models.LoginEndResponse' + type: string summary: Completes OIDC Web Login tags: - Auth /web/login/start: - post: + get: description: Generates state and nonce, then redirects the user to the OAuth2 authorization URL. operationId: WebStart + parameters: + - description: URL to redirect to if login succeeds + in: query + name: redirect + required: true + type: string + - description: URL to redirect to if login fails (optional) + in: query + name: failure + type: string produces: - application/json responses: - "200": - description: OK + "302": + description: Redirects to the OAuth2 authorization URL schema: - $ref: '#/definitions/models.LoginStartResponse' + type: string summary: Initiates OIDC Web Login tags: - Auth /web/logout: - post: + get: consumes: - application/json description: Provides the URL to initiate the logout process for the current user. operationId: Logout + parameters: + - description: URL to redirect to after logout + in: query + name: redirect + required: true + type: string produces: - application/json responses: - "200": - description: OK + "302": + description: Redirects to the OAuth2 logout URL schema: - $ref: '#/definitions/models.LogoutResponse' + type: string summary: Generate Logout URL tags: - Auth @@ -2757,20 +2744,11 @@ paths: - application/json description: Obtains and updates a new access token for the user. operationId: Refresh - parameters: - - description: End Login - in: body - name: data - required: true - schema: - $ref: '#/definitions/models.RefreshTokenRequest' produces: - application/json responses: - "200": - description: OK - schema: - $ref: '#/definitions/models.RefreshTokenResponse' + "204": + description: No Content summary: Refresh Access Token tags: - Auth diff --git a/internal/routers/routers.go b/internal/routers/routers.go index 415c1b17d..0f8880db1 100644 --- a/internal/routers/routers.go +++ b/internal/routers/routers.go @@ -85,11 +85,11 @@ func NewAPIRouter(ctx context.Context, o APIRouterOptions) (*gin.Engine, error) webGroup.Use(ginsession.New( session.SetCookieName(handlers.SESSION_ID_COOKIE_NAME), session.SetStore(o.SessionStore))) - webGroup.POST("/login/start", o.BrowserFlow.LoginStart) - webGroup.POST("/login/end", o.BrowserFlow.LoginEnd) + webGroup.GET("/login/start", o.BrowserFlow.LoginStart) + webGroup.GET("/login/end", o.BrowserFlow.LoginEnd) webGroup.GET("/user_info", o.BrowserFlow.UserInfo) webGroup.GET("/claims", o.BrowserFlow.Claims) - webGroup.POST("/logout", o.BrowserFlow.Logout) + webGroup.GET("/logout", o.BrowserFlow.Logout) // web.GET("/check_auth", o.BrowserFlow.CheckAuth) webGroup.POST("/refresh", o.BrowserFlow.Refresh) } diff --git a/pkg/oidcagent/agent.go b/pkg/oidcagent/agent.go index f908a93bb..79a130387 100644 --- a/pkg/oidcagent/agent.go +++ b/pkg/oidcagent/agent.go @@ -128,7 +128,7 @@ func NewOidcAgent(ctx context.Context, return auth, nil } -func (o *OidcAgent) LogoutURL(idToken string) (*url.URL, error) { +func (o *OidcAgent) LogoutURL(idToken string, redirect string) (*url.URL, error) { u, err := url.Parse(o.endSessionURL) if err != nil { return nil, err @@ -136,7 +136,7 @@ func (o *OidcAgent) LogoutURL(idToken string) (*url.URL, error) { params := u.Query() params.Add("client_id", o.clientID) params.Add("id_token_hint", idToken) - params.Add("post_logout_redirect_uri", o.redirectURL) + params.Add("post_logout_redirect_uri", redirect) u.RawQuery = params.Encode() return u, nil } diff --git a/pkg/oidcagent/agent_test.go b/pkg/oidcagent/agent_test.go index 5a4f3564b..74b0826ec 100644 --- a/pkg/oidcagent/agent_test.go +++ b/pkg/oidcagent/agent_test.go @@ -52,10 +52,9 @@ func (f *FakeIDTokenVerifier) Verify(ctx context.Context, rawIDToken string) (*o func TestLogoutURL(t *testing.T) { o := OidcAgent{ clientID: "test-client", - redirectURL: "https://example.com", endSessionURL: "https://auth.example.com/logout", } - actual, err := o.LogoutURL("my-id-token") + actual, err := o.LogoutURL("my-id-token", "https://example.com") require.NoError(t, err) expected := "https://auth.example.com/logout?client_id=test-client&id_token_hint=my-id-token&post_logout_redirect_uri=https%3A%2F%2Fexample.com" assert.Equal(t, expected, actual.String()) diff --git a/pkg/oidcagent/handlers.go b/pkg/oidcagent/handlers.go index eab07d704..f08b86ae2 100644 --- a/pkg/oidcagent/handlers.go +++ b/pkg/oidcagent/handlers.go @@ -6,7 +6,6 @@ import ( "crypto/tls" "encoding/base64" "encoding/json" - "fmt" "io" "net/http" "net/http/httputil" @@ -53,10 +52,30 @@ func (o *OidcAgent) prepareContext(c *gin.Context) context.Context { // @Tags Auth // @Accepts json // @Produce json -// @Success 200 {object} models.LoginStartResponse -// @Router /web/login/start [post] +// @Param redirect query string true "URL to redirect to if login succeeds" +// @Param failure query string false "URL to redirect to if login fails (optional)" +// @Success 302 {string} string "Redirects to the OAuth2 authorization URL" +// @Router /web/login/start [get] func (o *OidcAgent) LoginStart(c *gin.Context) { logger := o.logger + logger.Debug("handling login start request") + + query := struct { + Redirect string `form:"redirect"` + Failure string `form:"failure"` + }{} + err := c.ShouldBindQuery(&query) + if err != nil { + logger.With("error", err).Info("unable to bind query") + c.AbortWithStatus(http.StatusBadRequest) + return + } + if query.Redirect == "" { + logger.Info("redirect URL missing") + c.AbortWithStatus(http.StatusBadRequest) + return + } + state, err := randString(16) if err != nil { c.AbortWithStatus(http.StatusInternalServerError) @@ -72,15 +91,17 @@ func (o *OidcAgent) LoginStart(c *gin.Context) { logger = logger.With( "state", state, "nonce", nonce, + "redirect", query.Redirect, ) c.SetSameSite(http.SameSiteStrictMode) + c.SetCookie("redirect", query.Redirect, int(time.Hour.Seconds()), "/", "", c.Request.URL.Scheme == "https", true) + c.SetCookie("failure", query.Redirect, int(time.Hour.Seconds()), "/", "", c.Request.URL.Scheme == "https", true) c.SetCookie("state", state, int(time.Hour.Seconds()), "/", "", c.Request.URL.Scheme == "https", true) c.SetCookie("nonce", nonce, int(time.Hour.Seconds()), "/", "", c.Request.URL.Scheme == "https", true) logger.Debug("set cookies") - c.JSON(http.StatusOK, models.LoginStartResponse{ - AuthorizationRequestURL: o.oauthConfig.AuthCodeURL(state, oidc.Nonce(nonce)), - }) + url := o.oauthConfig.AuthCodeURL(state, oidc.Nonce(nonce)) + c.Redirect(http.StatusFound, url) } // LoginEnd completes the OIDC login process. @@ -90,153 +111,135 @@ func (o *OidcAgent) LoginStart(c *gin.Context) { // @Tags Auth // @Accepts json // @Produce json -// @Param data body models.LoginEndRequest true "End Login" -// @Success 200 {object} models.LoginEndResponse -// @Router /web/login/end [post] +// @Param code query string true "oauth2 code from authorization server" +// @Param state query string true "state value from the login start request" +// @Param error query string true "error message if login failed" +// @Success 302 {string} string "Redirects to the URLs specified in the login start request" +// @Router /web/login/end [get] func (o *OidcAgent) LoginEnd(c *gin.Context) { - var data models.LoginEndRequest - var accessToken, refreshToken, rawIDToken string - err := c.BindJSON(&data) + logger := o.logger + ctx := o.prepareContext(c) + logger.Debug("handling login end request") + + query := struct { + Code string `form:"code"` + State string `form:"state"` + Error string `form:"error"` + }{} + err := c.ShouldBindQuery(&query) if err != nil { c.AbortWithStatus(http.StatusBadRequest) return } - requestURL, err := url.Parse(data.RequestURL) + redirectURL, err := c.Cookie("redirect") if err != nil { c.AbortWithStatus(http.StatusBadRequest) return } + if redirectURL == "" { + c.AbortWithStatus(http.StatusBadRequest) + return + } + c.SetCookie("redirect", "", -1, "/", "", c.Request.URL.Scheme == "https", true) + failureURL, _ := c.Cookie("failure") + c.SetCookie("failure", "", -1, "/", "", c.Request.URL.Scheme == "https", true) + if failureURL == "" { + failureURL = redirectURL + } - logger := o.logger - ctx := o.prepareContext(c) - logger.Debug("handling login end request") - - values := requestURL.Query() - code := values.Get("code") - state := values.Get("state") - queryErr := values.Get("error") - - failed := state != "" && queryErr != "" - + failed := query.State != "" && query.Error != "" if failed { logger.Debug("login failed") - var status int - if queryErr == "login_required" { - status = http.StatusUnauthorized - } else { - status = http.StatusBadRequest - } - c.AbortWithStatus(status) + c.Redirect(302, failureURL) return } - handleAuth := state != "" && code != "" + if query.State == "" || query.Code == "" { + logger.Debug("state or code missing") + c.Redirect(302, failureURL) + return + } - loggedIn := false - if handleAuth { - logger.Debug("login success") - originalState, err := c.Cookie("state") - if err != nil { - logger.With( - "error", err, - ).Debug("unable to access state cookie") - c.AbortWithStatus(http.StatusInternalServerError) - return - } + originalState, err := c.Cookie("state") + if err != nil { + logger.With("error", err).Debug("unable to access state cookie") + c.Redirect(302, failureURL) + return + } - c.SetCookie("state", "", -1, "/", "", c.Request.URL.Scheme == "https", true) - if state != originalState { - logger.With( - "error", err, - ).Debug("state does not match") - c.AbortWithStatus(http.StatusBadRequest) - return - } + c.SetCookie("state", "", -1, "/", "", c.Request.URL.Scheme == "https", true) + if query.State != originalState { + logger.With("error", err).Debug("state does not match") + c.Redirect(302, failureURL) + return + } - nonce, err := c.Cookie("nonce") - if err != nil { - logger.With( - "error", err, - ).Debug("unable to get nonce cookie") - c.AbortWithStatus(http.StatusInternalServerError) - return - } - c.SetCookie("nonce", "", -1, "/", "", c.Request.URL.Scheme == "https", true) - - oauth2Token, err := o.oauthConfig.Exchange(ctx, code) - if err != nil { - logger.With( - "error", err, - ).Debug("unable to exchange token") - _ = c.AbortWithError(http.StatusInternalServerError, err) - return - } + session := ginsession.FromContext(c) - var ok bool - rawIDToken, ok = oauth2Token.Extra("id_token").(string) - if !ok { - logger.With( - "ok", ok, - ).Debug("unable to get id_token") - _ = c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("no id_token field in oauth2 token")) + if query.Code == "logout" { + session.Delete(IDTokenKey) + session.Delete(TokenKey) + if err := session.Save(); err != nil { + c.Redirect(302, failureURL) return } + c.Redirect(302, redirectURL) + return + } - idToken, err := o.verifier.Verify(ctx, rawIDToken) - if err != nil { - logger.With( - "error", err, - ).Debug("unable to verify id_token") - _ = c.AbortWithError(http.StatusInternalServerError, err) - return - } + nonce, err := c.Cookie("nonce") + if err != nil { + logger.With("error", err).Debug("unable to get nonce cookie") + c.Redirect(302, failureURL) + return + } + c.SetCookie("nonce", "", -1, "/", "", c.Request.URL.Scheme == "https", true) - if idToken.Nonce != nonce { - logger.Debug("nonce does not match") - _ = c.AbortWithError(http.StatusBadRequest, fmt.Errorf("nonce did not match")) - return - } + oauth2Token, err := o.oauthConfig.Exchange(ctx, query.Code) + if err != nil { + logger.With("error", err).Debug("unable to exchange token") + c.Redirect(302, failureURL) + return + } - session := ginsession.FromContext(c) - tokenString, err := tokenToJSONString(oauth2Token) - if err != nil { - logger.Debug("can't convert token to string") - _ = c.AbortWithError(http.StatusBadRequest, fmt.Errorf("can't convert token to string")) - return - } - session.Set(TokenKey, tokenString) - session.Set(IDTokenKey, rawIDToken) - if err := session.Save(); err != nil { - logger.With("error", err, - "id_token_size", len(rawIDToken)).Debug("can't save session storage") - c.AbortWithStatus(http.StatusInternalServerError) - return - } + rawIDToken, ok := oauth2Token.Extra("id_token").(string) + if !ok { + logger.With("ok", ok).Debug("unable to get id_token") + c.Redirect(302, failureURL) + return + } - logger.With("session_id", session.SessionID()).Debug("user is logged in") - loggedIn = true + idToken, err := o.verifier.Verify(ctx, rawIDToken) + if err != nil { + logger.With("error", err).Debug("unable to verify id_token") + c.Redirect(302, failureURL) + return + } - // extract the access_token and refresh_token from the oauth2Token - // to be returned in the response for the web auth token lifecycle. - accessToken = oauth2Token.Extra("access_token").(string) - refreshToken = oauth2Token.Extra("refresh_token").(string) - } else { - logger.Debug("checking if user is logged in") - loggedIn = isLoggedIn(c) + if idToken.Nonce != nonce { + logger.Debug("nonce does not match") + c.Redirect(302, failureURL) + return } - session := ginsession.FromContext(c) - logger.With("session_id", session.SessionID()).With("logged_in", loggedIn).Debug("complete") - res := models.LoginEndResponse{ - Handled: handleAuth, - LoggedIn: loggedIn, - AccessToken: accessToken, - RefreshToken: refreshToken, + tokenString, err := tokenToJSONString(oauth2Token) + if err != nil { + logger.Debug("can't convert token to string") + c.Redirect(302, failureURL) + return + } + session.Set(TokenKey, tokenString) + session.Set(IDTokenKey, rawIDToken) + if err := session.Save(); err != nil { + logger.With("error", err, "id_token_size", len(rawIDToken)).Debug("can't save session storage") + c.Redirect(302, failureURL) + return } - c.JSON(http.StatusOK, res) + logger.With("session_id", session.SessionID()).Debug("user is logged in") + c.Redirect(http.StatusFound, redirectURL) } // UserInfo retrieves details about the currently authenticated user. @@ -338,20 +341,11 @@ func (o *OidcAgent) Claims(c *gin.Context) { // @Tags Auth // @Accept json // @Produce json -// @Param data body models.RefreshTokenRequest true "End Login" -// @Success 200 {object} models.RefreshTokenResponse +// @Success 204 // @Router /web/refresh [post] func (o *OidcAgent) Refresh(c *gin.Context) { logger := o.logger - var data models.RefreshTokenRequest - - err := c.BindJSON(&data) - if err != nil { - c.AbortWithStatus(http.StatusBadRequest) - return - } - session := ginsession.FromContext(c) ctx := o.prepareContext(c) tokenRaw, ok := session.Get(TokenKey) @@ -391,11 +385,7 @@ func (o *OidcAgent) Refresh(c *gin.Context) { c.AbortWithStatus(http.StatusInternalServerError) return } - - c.JSON(http.StatusOK, models.RefreshTokenResponse{ - AccessToken: newToken.AccessToken, - RefreshToken: newToken.RefreshToken, - }) + c.Status(http.StatusNoContent) } // Logout provides the URL to log out the current user. @@ -405,32 +395,62 @@ func (o *OidcAgent) Refresh(c *gin.Context) { // @Tags Auth // @Accept json // @Produce json -// @Success 200 {object} models.LogoutResponse -// @Router /web/logout [post] +// @Param redirect query string true "URL to redirect to after logout" +// @Success 302 {string} string "Redirects to the OAuth2 logout URL" +// @Router /web/logout [get] func (o *OidcAgent) Logout(c *gin.Context) { + logger := o.logger + + query := struct { + Redirect string `form:"redirect"` + }{} + err := c.ShouldBindQuery(&query) + if err != nil { + logger.With("error", err).Info("unable to bind query") + c.AbortWithStatus(http.StatusBadRequest) + return + } + if query.Redirect == "" { + logger.Info("redirect URL missing") + c.AbortWithStatus(http.StatusBadRequest) + return + } + session := ginsession.FromContext(c) idToken, ok := session.Get(IDTokenKey) if !ok { - c.AbortWithStatus(http.StatusUnauthorized) + // If the user is not logged in, redirect to the specified URL + c.Redirect(http.StatusFound, query.Redirect) + return + } + state, err := randString(16) + if err != nil { + c.AbortWithStatus(http.StatusInternalServerError) return } - session.Delete(IDTokenKey) - session.Delete(TokenKey) - if err := session.Save(); err != nil { + // Yeah we reuse the LoginEnd function here to complete the logout process + u, err := url.Parse(o.redirectURL) + if err != nil { c.AbortWithStatus(http.StatusInternalServerError) return } + q := u.Query() + q.Set("code", "logout") + q.Set("state", state) + u.RawQuery = q.Encode() - logoutURL, err := o.LogoutURL(idToken.(string)) + // Redirect to the OIDC provider's logout URL + logoutURL, err := o.LogoutURL(idToken.(string), u.String()) if err != nil { c.AbortWithStatus(http.StatusInternalServerError) return } - c.JSON(http.StatusOK, models.LogoutResponse{ - LogoutURL: logoutURL.String(), - }) + c.SetSameSite(http.SameSiteStrictMode) + c.SetCookie("redirect", query.Redirect, int(time.Hour.Seconds()), "/", "", c.Request.URL.Scheme == "https", true) + c.SetCookie("state", state, int(time.Hour.Seconds()), "/", "", c.Request.URL.Scheme == "https", true) + c.Redirect(http.StatusFound, logoutURL.String()) } func (o *OidcAgent) CodeFlowProxy(c *gin.Context) { diff --git a/pkg/oidcagent/handlers_test.go b/pkg/oidcagent/handlers_test.go index ae13d572c..dfea0e7c8 100644 --- a/pkg/oidcagent/handlers_test.go +++ b/pkg/oidcagent/handlers_test.go @@ -1,7 +1,6 @@ package oidcagent import ( - "bytes" "context" "encoding/json" "github.com/nexodus-io/nexodus/pkg/oidcagent/models" @@ -35,45 +34,20 @@ func TestLoginStart(t *testing.T) { } gin.SetMode(gin.TestMode) r := gin.New() - r.POST("/login/start", auth.LoginStart) - req, _ := http.NewRequest("POST", "/login/start", nil) + r.GET("/login/start", auth.LoginStart) + req, _ := http.NewRequest("GET", "/login/start?redirect=a&failure=b", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) - require.Equal(t, http.StatusOK, w.Code) - - data, err := io.ReadAll(w.Body) - require.NoError(t, err) - - var response models.LoginStartResponse - err = json.Unmarshal(data, &response) - require.NoError(t, err) - - assert.NotEmpty(t, response.AuthorizationRequestURL) + require.Equal(t, http.StatusFound, w.Code) + assert.Equal(t, "https://auth.example.com/auth?state=foo&client_id=bar", w.Header().Get("Location")) cookies := w.Result().Cookies() - assert.Equal(t, 2, len(cookies)) - assert.Equal(t, "state", cookies[0].Name) - assert.Equal(t, "nonce", cookies[1].Name) -} - -func TestLoginEnd_AuthErrorLoginRequired(t *testing.T) { - auth := &OidcAgent{ - logger: zap.NewExample().Sugar(), - } - gin.SetMode(gin.TestMode) - r := gin.New() - r.POST("/login/end", auth.LoginEnd) - loginEndRequest := models.LoginEndRequest{ - RequestURL: "https://example.com?state=foo&error=login_required", - } - reqBody, err := json.Marshal(&loginEndRequest) - require.NoError(t, err) - req, _ := http.NewRequest("POST", "/login/end", bytes.NewReader(reqBody)) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - require.Equal(t, http.StatusUnauthorized, w.Code) + assert.Equal(t, 4, len(cookies)) + assert.Equal(t, "redirect", cookies[0].Name) + assert.Equal(t, "failure", cookies[1].Name) + assert.Equal(t, "state", cookies[2].Name) + assert.Equal(t, "nonce", cookies[3].Name) } func TestLoginEnd_AuthErrorOther(t *testing.T) { @@ -82,13 +56,8 @@ func TestLoginEnd_AuthErrorOther(t *testing.T) { } gin.SetMode(gin.TestMode) r := gin.New() - r.POST("/login/end", auth.LoginEnd) - loginEndRequest := models.LoginEndRequest{ - RequestURL: "https://example.com?state=foo&error=kittens", - } - reqBody, err := json.Marshal(&loginEndRequest) - require.NoError(t, err) - req, _ := http.NewRequest("POST", "/login/end", bytes.NewReader(reqBody)) + r.GET("/login/end", auth.LoginEnd) + req, _ := http.NewRequest("GET", "/login/end", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -134,13 +103,17 @@ func TestLoginEnd_HandleLogin(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() r.Use(ginsession.New()) - r.POST("/login/end", auth.LoginEnd) - loginEndRequest := models.LoginEndRequest{ - RequestURL: "https://example.com?state=foo&code=kittens", - } - reqBody, err := json.Marshal(&loginEndRequest) - require.NoError(t, err) - req, _ := http.NewRequest("POST", "/login/end", bytes.NewReader(reqBody)) + r.GET("/login/end", auth.LoginEnd) + + req, _ := http.NewRequest("GET", "/login/end?state=foo&code=kittens", nil) + req.AddCookie(&http.Cookie{ + Name: "redirect", + Value: "https://example.com/ok", + }) + req.AddCookie(&http.Cookie{ + Name: "failure", + Value: "https://example.com/failed", + }) req.AddCookie(&http.Cookie{ Name: "state", Value: "foo", @@ -152,107 +125,74 @@ func TestLoginEnd_HandleLogin(t *testing.T) { w := httptest.NewRecorder() r.ServeHTTP(w, req) - require.Equal(t, http.StatusOK, w.Code) - - data, err := io.ReadAll(w.Body) - require.NoError(t, err) - - var response models.LoginEndResponse - err = json.Unmarshal(data, &response) - require.NoError(t, err) + require.Equal(t, http.StatusFound, w.Code) + assert.Equal(t, "https://example.com/ok", w.Header().Get("Location")) - assert.Equal(t, true, response.LoggedIn) - assert.Equal(t, true, response.Handled) } -func TestLoginEnd_LoggedIn(t *testing.T) { +func TestLoginEnd_NotLoggedIn(t *testing.T) { auth := &OidcAgent{ logger: zap.NewExample().Sugar(), + verifier: &FakeIDTokenVerifier{ + VerifyFn: func(ctx context.Context, rawIDToken string) (*oidc.IDToken, error) { + t := &oidc.IDToken{ + Nonce: "bar", + } + return t, nil + }, + }, + oauthConfig: &FakeOauthConfig{ + ExchangeFn: func(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + t := &oauth2.Token{ + AccessToken: "floofy", + RefreshToken: "kittens", + } + field := reflect.ValueOf(t).Elem().FieldByName("raw") + setUnexportedField(field, map[string]interface{}{ + "id_token": "boxofkittehs", + "access_token": "floofy", + "refresh_token": "kittens", + }) + return t, nil + }, + }, } - gin.SetMode(gin.TestMode) - r := gin.New() + session.InitManager( session.SetStore( cookie.NewCookieStore( - cookie.SetCookieName("demo_cookie_store_id"), - cookie.SetHashKey([]byte(auth.cookieKey)), + cookie.SetHashKey([]byte("secretkey")), ), ), ) - r.Use(ginsession.New()) - r.Use(func(c *gin.Context) { - session := ginsession.FromContext(c) - t := oauth2.Token{ - AccessToken: "kittens", - } - token, _ := tokenToJSONString(&t) - session.Set(TokenKey, token) - if err := session.Save(); err != nil { - c.AbortWithStatus(http.StatusInternalServerError) - return - } - c.Next() - }) - r.POST("/login/end", auth.LoginEnd) - loginEndRequest := models.LoginEndRequest{ - RequestURL: "https://example.com", - } - reqBody, err := json.Marshal(&loginEndRequest) - require.NoError(t, err) - req, _ := http.NewRequest("POST", "/login/end", bytes.NewReader(reqBody)) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - require.Equal(t, http.StatusOK, w.Code) - - data, err := io.ReadAll(w.Body) - require.NoError(t, err) - - var response models.LoginEndResponse - err = json.Unmarshal(data, &response) - require.NoError(t, err) - - assert.True(t, response.LoggedIn) - assert.False(t, response.Handled) -} - -func TestLoginEnd_NotLoggedIn(t *testing.T) { - auth := &OidcAgent{ - logger: zap.NewExample().Sugar(), - } gin.SetMode(gin.TestMode) r := gin.New() - session.InitManager( - session.SetStore( - cookie.NewCookieStore( - cookie.SetCookieName("demo_cookie_store_id"), - cookie.SetHashKey([]byte(auth.cookieKey)), - ), - ), - ) r.Use(ginsession.New()) - r.POST("/login/end", auth.LoginEnd) - loginEndRequest := models.LoginEndRequest{ - RequestURL: "https://example.com", - } - reqBody, err := json.Marshal(&loginEndRequest) - require.NoError(t, err) - req, _ := http.NewRequest("POST", "/login/end", bytes.NewReader(reqBody)) + r.GET("/login/end", auth.LoginEnd) + + req, _ := http.NewRequest("GET", "/login/end?state=foo&code=kittens&error=failed", nil) + req.AddCookie(&http.Cookie{ + Name: "redirect", + Value: "https://example.com/ok", + }) + req.AddCookie(&http.Cookie{ + Name: "failure", + Value: "https://example.com/failed", + }) + req.AddCookie(&http.Cookie{ + Name: "state", + Value: "foo", + }) + req.AddCookie(&http.Cookie{ + Name: "nonce", + Value: "bar", + }) w := httptest.NewRecorder() r.ServeHTTP(w, req) - require.Equal(t, http.StatusOK, w.Code) - - data, err := io.ReadAll(w.Body) - require.NoError(t, err) - - var response models.LoginEndResponse - err = json.Unmarshal(data, &response) - require.NoError(t, err) - - assert.False(t, response.LoggedIn) - assert.False(t, response.Handled) + require.Equal(t, http.StatusFound, w.Code) + assert.Equal(t, "https://example.com/failed", w.Header().Get("Location")) } func TestUserInfo_NotLoggedIn(t *testing.T) { diff --git a/pkg/oidcagent/middleware.go b/pkg/oidcagent/middleware.go index 46ed6e910..81982a8cc 100644 --- a/pkg/oidcagent/middleware.go +++ b/pkg/oidcagent/middleware.go @@ -14,15 +14,17 @@ import ( func (a *OidcAgent) OriginVerifier() gin.HandlerFunc { return func(c *gin.Context) { origin := c.Request.Header.Get("Origin") - permitted := false - for _, o := range a.trustedOrigins { - if origin == o { - permitted = true - break + if origin != "" { + permitted := false + for _, o := range a.trustedOrigins { + if origin == o { + permitted = true + break + } + } + if !permitted { + c.AbortWithStatus(http.StatusUnauthorized) } - } - if !permitted { - c.AbortWithStatus(http.StatusUnauthorized) } c.Next() } diff --git a/pkg/oidcagent/models/models.go b/pkg/oidcagent/models/models.go index 4f2fceb73..7032e46ca 100644 --- a/pkg/oidcagent/models/models.go +++ b/pkg/oidcagent/models/models.go @@ -2,25 +2,6 @@ package models import "time" -type LoginStartResponse struct { - AuthorizationRequestURL string `json:"authorization_request_url"` -} - -type LoginEndRequest struct { - RequestURL string `json:"request_url"` -} - -type LoginEndResponse struct { - Handled bool `json:"handled"` - LoggedIn bool `json:"logged_in"` - AccessToken string `json:"access_token,omitempty"` - RefreshToken string `json:"refresh_token,omitempty"` -} - -type LogoutResponse struct { - LogoutURL string `json:"logout_url"` -} - type UserInfoResponse struct { Subject string `json:"sub"` PreferredUsername string `json:"preferred_username"` @@ -32,15 +13,6 @@ type UserInfoResponse struct { Email string `json:"email"` } -type RefreshTokenRequest struct { - RefreshToken string `json:"refresh_token,omitempty"` -} - -type RefreshTokenResponse struct { - AccessToken string `json:"access_token,omitempty"` - RefreshToken string `json:"refresh_token,omitempty"` -} - type DeviceStartResponse struct { // TODO: Remove this once golang/oauth2 supports device flow // and when coreos/go-oidc adds device_authorization_endpoint discovery @@ -51,8 +23,3 @@ type DeviceStartResponse struct { // in relation to the server. ServerTime *time.Time `json:"server_time" format:"date-time"` } - -type CheckAuthResponse struct { - Status string `json:"status"` - Message string `json:"message"` -} diff --git a/pkg/oidcagent/router.go b/pkg/oidcagent/router.go index 8a0b141ee..2f20e81de 100644 --- a/pkg/oidcagent/router.go +++ b/pkg/oidcagent/router.go @@ -22,8 +22,8 @@ func NewCodeFlowRouter(auth *OidcAgent) *gin.Engine { func AddCodeFlowRoutes(r gin.IRouter, auth *OidcAgent) { r.Use(auth.OriginVerifier()) - r.POST("/login/start", auth.LoginStart) - r.POST("/login/end", auth.LoginEnd) + r.GET("/login/start", auth.LoginStart) + r.GET("/login/end", auth.LoginEnd) r.GET("/user_info", auth.UserInfo) r.GET("/claims", auth.Claims) r.POST("/logout", auth.Logout) diff --git a/ui/src/providers/AuthProvider.tsx b/ui/src/providers/AuthProvider.tsx index e057bf052..38651969c 100644 --- a/ui/src/providers/AuthProvider.tsx +++ b/ui/src/providers/AuthProvider.tsx @@ -1,197 +1,89 @@ import { AuthProvider, UserIdentity } from "react-admin"; import { RefreshManager } from "./RefreshManager"; +import { red } from "@mui/material/colors"; -const cleanup = () => { - RefreshManager.stopRefreshing(); - console.log("Cleanup Called"); - // Remove the ?code&state from the URL - window.history.replaceState( - {}, - window.document.title, - window.location.origin, - ); - console.log("Window location after cleanup:", window.location.href); -}; - -export const goOidcAgentAuthProvider = (api: string): AuthProvider => ({ - login: async (params = {}) => { - // 1. Redirect to the issuer to ask authentication - if (!params.code || !params.state) { - const request = new Request(`${api}/web/login/start`, { - method: "POST", - credentials: "include", - }); - try { - const response = await fetch(request); - const data = await response.json(); - if (response && data) { - window.location.replace(data.authorization_request_url); - } - } catch (err: any) { - throw new Error("Network error"); - } - } - - // 2. We came back from the issuer with ?code infos in query params - const request = new Request(`${api}/web/login/end`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ request_url: window.location.href }), - }); - try { - const response = await fetch(request); - const data = await response.json(); - if (response.ok && data) { - if (data.access_token !== null) { - localStorage.setItem("AccessToken", data.access_token); - console.debug(`Stored Access Token: ${data.access_token}`); - } - if (data.refresh_token !== null) { - localStorage.setItem("RefreshToken", data.refresh_token); - console.debug(`Stored Refresh Token: ${data.refresh_token}`); - } - return data.handled && data.logged_in - ? Promise.resolve() - : Promise.reject(); - } else { - console.log("Login failed:", response.statusText); - cleanup(); - throw new Error("Login failed"); - } - } catch (err: any) { - console.log("Login Error:", err); - cleanup(); - throw new Error(err.statusText || "Unknown error"); - } - }, - - logout: async () => { - const request = new Request(`${api}/web/logout`, { - method: "post", +export const goOidcAgentAuthProvider = (api: string): AuthProvider => { + const getMe = async (): Promise => { + const request = new Request(`${api}/api/users/me`, { credentials: "include", }); + let id; try { const response = await fetch(request); - if (response.status === 401) { - cleanup(); // Run cleanup if status is 401 - return Promise.resolve(); - } else if (response.status !== 200) { - // If the status is neither 401 nor 200, something went wrong. - cleanup(); - throw new Error(`Logout failed with status ${response.status}`); + if (!response || response.status !== 200) { + return Promise.reject(); } const data = await response.json(); - if (response && data) { - window.location.replace(data.logout_url); - } else { - // If the response object or data is somehow null or undefined, call cleanup(). - cleanup(); - throw new Error( - "Logout failed, response or data is null or undefined.", - ); + if (!data) { + return Promise.reject(); } + + return { + id: data.id, + fullName: data.full_name, + avatar: data.picture, + email: data.email, + }; } catch (err: any) { - // If an exception is thrown while trying to logout, call the cleanup function. - cleanup(); throw new Error(err.statusText); } - }, + }; - checkError: async (error: any) => { - const status = error.status; - if (status === 401) { - return Promise.reject(); - } - return Promise.resolve(); - }, + return { + login: async (params = {}) => { + console.log("Login Called!!"); - checkAuth: async () => { - console.log("Check Auth Called"); - const token = localStorage.getItem("AccessToken"); + let redirect = window.location.href; + if (redirect.endsWith("#/login")) { + // replace #/login with empty string + redirect = redirect.replace("#/login", ""); + } + console.log("login", params); + console.log("redirect", redirect); - if (!token) { - console.debug("Token not found, calling /web/login/end"); - const request = new Request(`${api}/web/login/end`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ request_url: window.location.href }), - }); + // Send the user to the authentication server, and have them come back to the redirect URL + window.location.replace(`${api}/web/login/start?redirect=${redirect}`); + }, + logout: async () => { + console.log("Logout Called"); try { - const response = await fetch(request); - const data = await response.json(); - return data.logged_in ? Promise.resolve() : Promise.reject(); - } catch (err: any) { - console.log("Error during login:", err); - return Promise.reject(); - } - } - - if (token) { - const refreshToken = localStorage.getItem("RefreshToken"); - if (!refreshToken) { - console.debug("No refresh token found. Cannot start refresh."); - return Promise.reject(); + await getMe(); + } catch (err) { + // If we are not logged in, then we don't need to log out + return; } - if (!RefreshManager.hasStartedRefreshInterval) { - console.debug("Starting the refresh interval."); - - const intervalId = window.setInterval(() => { - RefreshManager.startRefreshing(api); - }, RefreshManager.REFRESH_INTERVAL_MS); - - // Update RefreshManager's state - RefreshManager.setRefreshIntervalId(intervalId); - RefreshManager.setHasStartedRefreshInterval(true); - } + RefreshManager.stopRefreshing(); + let redirect = window.location.href; + // does the redirect contain a hash? If so, remove it. + redirect = redirect.split("#")[0]; + window.location.replace(`${api}/web/logout?redirect=${redirect}`); + }, - try { - const refreshToken = localStorage.getItem("RefreshToken"); - if (!refreshToken) { - console.log("No refresh token found. Cannot refresh the token."); - return Promise.reject(); - } - await RefreshManager.refreshToken(api); - return Promise.resolve(); - } catch (err) { - console.log("Error refreshing the token:", err); + checkError: async (error: any) => { + const status = error.status; + if (status === 401) { return Promise.reject(); } - } - }, + return Promise.resolve(); + }, - getPermissions: async () => { - console.log("Get Permissions Called"); - // TODO: Add a callback so people can decode the claims - return Promise.resolve(); - }, + checkAuth: async () => { + console.log("Check Auth Called!"); + await getMe(); + await RefreshManager.startRefreshing(api); + }, - getIdentity: async (): Promise => { - console.log("Get Identity Called"); - const request = new Request(`${api}/api/users/me`, { - credentials: "include", - }); - let id; - try { - const response = await fetch(request); - const data = await response.json(); - if (response && data) { - id = { - id: data.id, - fullName: data.full_name, - avatar: data.picture, - email: data.email, - } as UserIdentity; - } - } catch (err: any) { - throw new Error(err.statusText); - } - if (id) { - return Promise.resolve(id); - } - return Promise.reject(); - }, -}); + getIdentity: async (): Promise => { + console.log("Get Identity Called"); + return await getMe(); + }, + + getPermissions: async () => { + console.log("Get Permissions Called"); + // TODO: Add a callback so people can decode the claims + return Promise.resolve(); + }, + }; +}; diff --git a/ui/src/providers/AuthTypes.ts b/ui/src/providers/AuthTypes.ts deleted file mode 100644 index 1391028a9..000000000 --- a/ui/src/providers/AuthTypes.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface RefreshTokenResponse { - access_token?: string; - refresh_token?: string; -} - -export interface JwtDecodeExp { - exp: number; -} diff --git a/ui/src/providers/RefreshManager.ts b/ui/src/providers/RefreshManager.ts index a033cc14f..310ab19e3 100644 --- a/ui/src/providers/RefreshManager.ts +++ b/ui/src/providers/RefreshManager.ts @@ -1,49 +1,21 @@ -import { JwtDecodeExp, RefreshTokenResponse } from "./AuthTypes"; import { jwtDecode } from "jwt-decode"; export class RefreshManager { - static hasStartedRefreshInterval = false; - static refreshIntervalId: number | undefined; + static refreshIntervalId: number | undefined = undefined; static REFRESH_INTERVAL_MS = 90 * 1000; - static setRefreshIntervalId(intervalId: number) { - this.refreshIntervalId = intervalId; - } - - static setHasStartedRefreshInterval(value: boolean) { - this.hasStartedRefreshInterval = value; - } - // one-time refresh POST - static async postRefreshTokens( - api: string, - ): Promise { - const refreshToken = localStorage.getItem("RefreshToken"); + static async postRefresh(api: string): Promise { const request = new Request(`${api}/web/refresh`, { method: "POST", credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refresh_token: refreshToken }), }); try { const response = await fetch(request); - if (response.ok) { - const data: RefreshTokenResponse = await response.json(); - // Uncomment to print refresh tokens in console - // console.debug("Refresh tokens new tokens:", data); - if (data.access_token) { - // Decode and print the expiration time of the new access token - const decoded: JwtDecodeExp = jwtDecode(data.access_token); - console.debug( - "New access token expires at:", - new Date(decoded.exp * 1000).toLocaleString(), - ); - } - return data; - } else { + if (!response.ok) { console.error( - `Received ${response.status} from server during token fetch.`, + `Received ${response.status} from server during refresh.`, ); if ( response.headers.get("Content-Type")?.includes("application/json") @@ -51,55 +23,27 @@ export class RefreshManager { const errorData = await response.json(); console.error("Error data from server:", errorData); } - return null; } } catch (error) { console.error("An error occurred during token fetch:", error); - return null; } } // long-running refreshing on a timer static async startRefreshing(api: string): Promise { - try { - console.debug("Start Refreshing Firing."); - const data = await this.postRefreshTokens(api); - if (data && (data.access_token || data.refresh_token)) { - console.debug("Token interval refreshed successfully."); - } else { - console.debug("Failed to refresh the token, clearing refresh interval"); - clearInterval(this.refreshIntervalId as any); - this.hasStartedRefreshInterval = false; - } - console.debug("Refresh poller completed."); - } catch (e) { - console.error("Error in startRefreshing:", e); - } - } - - static async refreshToken(api: string): Promise { - const data = await this.postRefreshTokens(api); - if (data) { - this.handleTokenResponse(data); - } else { - console.log("Failed to refresh the token."); - return Promise.reject(); - } - } - - // Store the tokens in local storage and log the expiration date - static handleTokenResponse(data: RefreshTokenResponse) { - if (data.refresh_token) { - const decodedToken = jwtDecode(data.refresh_token!); - const expirationDate = new Date(decodedToken.exp * 1000); - console.debug("Refresh Token will expire on: ", expirationDate); - localStorage.setItem("RefreshToken", data.refresh_token); - } - if (data.access_token) { - const decodedToken = jwtDecode(data.access_token!); - const expirationDate = new Date(decodedToken.exp * 1000); - console.debug("Token will expire on: ", expirationDate); - localStorage.setItem("AccessToken", data.access_token); + console.debug("startRefreshing called."); + if (this.refreshIntervalId === undefined) { + console.debug("Starting the refresh interval."); + this.refreshIntervalId = window.setInterval(async () => { + try { + console.debug("Start Refreshing Firing."); + await this.postRefresh(api); + console.debug("refreshed successfully."); + } catch (e) { + console.error("Error in refresh:", e); + this.stopRefreshing(); + } + }, RefreshManager.REFRESH_INTERVAL_MS); } } @@ -107,9 +51,8 @@ export class RefreshManager { static stopRefreshing(): void { if (this.refreshIntervalId !== undefined) { console.log("Clearing refresh interval"); - clearInterval(this.refreshIntervalId); + window.clearInterval(this.refreshIntervalId); this.refreshIntervalId = undefined; - this.hasStartedRefreshInterval = false; } } }