snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
This commit is contained in:
375
selfservice/flow/logout/handler.go
Normal file
375
selfservice/flow/logout/handler.go
Normal file
@@ -0,0 +1,375 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package logout
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
"github.com/ory/kratos/x/redir"
|
||||
"github.com/ory/x/httprouterx"
|
||||
"github.com/ory/x/httpx"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/kratos/x/events"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/x/decoderx"
|
||||
"github.com/ory/x/sqlcon"
|
||||
"github.com/ory/x/urlx"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/selfservice/errorx"
|
||||
"github.com/ory/kratos/session"
|
||||
"github.com/ory/kratos/x"
|
||||
)
|
||||
|
||||
const (
|
||||
RouteInitBrowserFlow = "/self-service/logout/browser"
|
||||
RouteAPIFlow = "/self-service/logout/api"
|
||||
RouteSubmitFlow = "/self-service/logout"
|
||||
)
|
||||
|
||||
type (
|
||||
dependencies interface {
|
||||
httpx.WriterProvider
|
||||
nosurfx.CSRFProvider
|
||||
session.ManagementProvider
|
||||
session.PersistenceProvider
|
||||
errorx.ManagementProvider
|
||||
config.Provider
|
||||
}
|
||||
HandlerProvider interface {
|
||||
LogoutHandler() *Handler
|
||||
}
|
||||
Handler struct{ d dependencies }
|
||||
)
|
||||
|
||||
func NewHandler(d dependencies) *Handler { return &Handler{d: d} }
|
||||
|
||||
func (h *Handler) RegisterPublicRoutes(router *httprouterx.RouterPublic) {
|
||||
h.d.CSRFHandler().IgnorePath(RouteAPIFlow)
|
||||
|
||||
router.GET(RouteInitBrowserFlow, h.createBrowserLogoutFlow)
|
||||
router.DELETE(RouteAPIFlow, h.performNativeLogout)
|
||||
router.GET(RouteSubmitFlow, h.updateLogoutFlow)
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterAdminRoutes(admin *httprouterx.RouterAdmin) {
|
||||
admin.GET(RouteInitBrowserFlow, redir.RedirectToPublicRoute(h.d))
|
||||
admin.DELETE(RouteAPIFlow, redir.RedirectToPublicRoute(h.d))
|
||||
admin.GET(RouteSubmitFlow, redir.RedirectToPublicRoute(h.d))
|
||||
}
|
||||
|
||||
// Logout Flow
|
||||
//
|
||||
// swagger:model logoutFlow
|
||||
type logoutFlow struct {
|
||||
// LogoutURL can be opened in a browser to sign the user out.
|
||||
//
|
||||
// format: uri
|
||||
// required: true
|
||||
LogoutURL string `json:"logout_url"`
|
||||
|
||||
// LogoutToken can be used to perform logout using AJAX.
|
||||
//
|
||||
// required: true
|
||||
LogoutToken string `json:"logout_token"`
|
||||
}
|
||||
|
||||
// Create Browser Logout Flow Parameters
|
||||
//
|
||||
// swagger:parameters createBrowserLogoutFlow
|
||||
//
|
||||
//nolint:deadcode,unused
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type createBrowserLogoutFlow struct {
|
||||
// HTTP Cookies
|
||||
//
|
||||
// If you call this endpoint from a backend, please include the
|
||||
// original Cookie header in the request.
|
||||
//
|
||||
// in: header
|
||||
// name: cookie
|
||||
Cookie string `json:"cookie"`
|
||||
|
||||
// Return to URL
|
||||
//
|
||||
// The URL to which the browser should be redirected to after the logout
|
||||
// has been performed.
|
||||
//
|
||||
// in: query
|
||||
ReturnTo string `json:"return_to"`
|
||||
}
|
||||
|
||||
// swagger:route GET /self-service/logout/browser frontend createBrowserLogoutFlow
|
||||
//
|
||||
// # Create a Logout URL for Browsers
|
||||
//
|
||||
// This endpoint initializes a browser-based user logout flow and a URL which can be used to log out the user.
|
||||
//
|
||||
// This endpoint is NOT INTENDED for API clients and only works
|
||||
// with browsers (Chrome, Firefox, ...). For API clients you can
|
||||
// call the `/self-service/logout/api` URL directly with the Ory Session Token.
|
||||
//
|
||||
// The URL is only valid for the currently signed in user. If no user is signed in, this endpoint returns
|
||||
// a 401 error.
|
||||
//
|
||||
// When calling this endpoint from a backend, please ensure to properly forward the HTTP cookies.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: logoutFlow
|
||||
// 400: errorGeneric
|
||||
// 401: errorGeneric
|
||||
// 500: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: kratos-public-medium
|
||||
func (h *Handler) createBrowserLogoutFlow(w http.ResponseWriter, r *http.Request) {
|
||||
sess, err := h.d.SessionManager().FetchFromRequest(r.Context(), r, session.ExpandNothing, identity.ExpandNothing)
|
||||
if err != nil {
|
||||
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
conf := h.d.Config()
|
||||
|
||||
requestURL := x.RequestURL(r)
|
||||
|
||||
var returnTo *url.URL
|
||||
|
||||
if requestURL.Query().Get("return_to") != "" {
|
||||
// Pre-validate the return to URL which is contained in the HTTP request.
|
||||
returnTo, err = redir.SecureRedirectTo(r,
|
||||
h.d.Config().SelfServiceFlowLogoutRedirectURL(r.Context()),
|
||||
redir.SecureRedirectUseSourceURL(requestURL.String()),
|
||||
redir.SecureRedirectAllowURLs(conf.SelfServiceBrowserAllowedReturnToDomains(r.Context())),
|
||||
redir.SecureRedirectAllowSelfServiceURLs(conf.SelfPublicURL(r.Context())),
|
||||
)
|
||||
if err != nil {
|
||||
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
params := url.Values{"token": {sess.LogoutToken}}
|
||||
|
||||
if returnTo != nil {
|
||||
params.Set("return_to", returnTo.String())
|
||||
}
|
||||
|
||||
h.d.Writer().Write(w, r, &logoutFlow{
|
||||
LogoutToken: sess.LogoutToken,
|
||||
LogoutURL: urlx.CopyWithQuery(urlx.AppendPaths(h.d.Config().SelfPublicURL(r.Context()), RouteSubmitFlow), params).String(),
|
||||
})
|
||||
}
|
||||
|
||||
// Perform Native Logout Parameters
|
||||
//
|
||||
// swagger:parameters performNativeLogout
|
||||
//
|
||||
//nolint:deadcode,unused
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type performNativeLogout struct {
|
||||
// in: body
|
||||
// required: true
|
||||
Body performNativeLogoutBody
|
||||
}
|
||||
|
||||
// Perform Native Logout Request Body
|
||||
//
|
||||
// swagger:model performNativeLogoutBody
|
||||
//
|
||||
//nolint:deadcode,unused
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type performNativeLogoutBody struct {
|
||||
// The Session Token
|
||||
//
|
||||
// Invalidate this session token.
|
||||
//
|
||||
// required: true
|
||||
SessionToken string `json:"session_token"`
|
||||
}
|
||||
|
||||
// swagger:route DELETE /self-service/logout/api frontend performNativeLogout
|
||||
//
|
||||
// # Perform Logout for Native Apps
|
||||
//
|
||||
// Use this endpoint to log out an identity using an Ory Session Token. If the Ory Session Token was successfully
|
||||
// revoked, the server returns a 204 No Content response. A 204 No Content response is also sent when
|
||||
// the Ory Session Token has been revoked already before.
|
||||
//
|
||||
// If the Ory Session Token is malformed or does not exist a 403 Forbidden response will be returned.
|
||||
//
|
||||
// This endpoint does not remove any HTTP
|
||||
// Cookies - use the Browser-Based Self-Service Logout Flow instead.
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 204: emptyResponse
|
||||
// 400: errorGeneric
|
||||
// default: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: kratos-public-medium
|
||||
func (h *Handler) performNativeLogout(w http.ResponseWriter, r *http.Request) {
|
||||
var p performNativeLogoutBody
|
||||
if err := decoderx.Decode(r, &p,
|
||||
decoderx.HTTPJSONDecoder(),
|
||||
decoderx.HTTPDecoderAllowedMethods("DELETE")); err != nil {
|
||||
h.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
revoked, err := h.d.SessionPersister().RevokeSessionByToken(r.Context(), p.SessionToken)
|
||||
if err != nil {
|
||||
if errors.Is(err, sqlcon.ErrNoRows()) {
|
||||
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrForbidden().WithReason("The provided Ory Session Token could not be found, is invalid, or otherwise malformed.")))
|
||||
return
|
||||
}
|
||||
|
||||
h.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
trace.SpanFromContext(r.Context()).AddEvent(events.NewSessionRevoked(r.Context(), revoked.ID, revoked.IdentityID))
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Update Logout Flow Parameters
|
||||
//
|
||||
// swagger:parameters updateLogoutFlow
|
||||
//
|
||||
//nolint:deadcode,unused
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type updateLogoutFlow struct {
|
||||
// A Valid Logout Token
|
||||
//
|
||||
// If you do not have a logout token because you only have a session cookie,
|
||||
// call `/self-service/logout/browser` to generate a URL for this endpoint.
|
||||
//
|
||||
// in: query
|
||||
Token string `json:"token"`
|
||||
|
||||
// The URL to return to after the logout was completed.
|
||||
//
|
||||
// in: query
|
||||
ReturnTo string `json:"return_to"`
|
||||
|
||||
// HTTP Cookies
|
||||
//
|
||||
// When using the SDK in a browser app, on the server side you must include the HTTP Cookie Header
|
||||
// sent by the client to your server here. This ensures that CSRF and session cookies are respected.
|
||||
//
|
||||
// in: header
|
||||
// name: Cookie
|
||||
Cookies string `json:"Cookie"`
|
||||
}
|
||||
|
||||
// swagger:route GET /self-service/logout frontend updateLogoutFlow
|
||||
//
|
||||
// # Update Logout Flow
|
||||
//
|
||||
// This endpoint logs out an identity in a self-service manner.
|
||||
//
|
||||
// If the `Accept` HTTP header is not set to `application/json`, the browser will be redirected (HTTP 303 See Other)
|
||||
// to the `return_to` parameter of the initial request or fall back to `urls.default_return_to`.
|
||||
//
|
||||
// If the `Accept` HTTP header is set to `application/json`, a 204 No Content response
|
||||
// will be sent on successful logout instead.
|
||||
//
|
||||
// This endpoint is NOT INTENDED for API clients and only works
|
||||
// with browsers (Chrome, Firefox, ...). For API clients you can
|
||||
// call the `/self-service/logout/api` URL directly with the Ory Session Token.
|
||||
//
|
||||
// More information can be found at [Ory Kratos User Logout Documentation](https://www.ory.com/docs/next/kratos/self-service/flows/user-logout).
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 303: emptyResponse
|
||||
// 204: emptyResponse
|
||||
// default: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: kratos-public-high
|
||||
func (h *Handler) updateLogoutFlow(w http.ResponseWriter, r *http.Request) {
|
||||
expected := r.URL.Query().Get("token")
|
||||
if len(expected) == 0 {
|
||||
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, errors.WithStack(herodot.ErrBadRequest().WithReason("Please include a token in the URL query.")))
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := h.d.SessionManager().FetchFromRequest(r.Context(), r, session.ExpandNothing, identity.ExpandNothing)
|
||||
if err != nil {
|
||||
// We could handle `session.ErrNoActiveSessionFound` gracefully with `h.completeLogout()` here but that would
|
||||
// actually be an issue as it incorrectly indicates to clients that the session has been removed even if
|
||||
// `RevokeSessionByToken` has not actually been called.
|
||||
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if sess.LogoutToken != expected {
|
||||
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, errors.WithStack(herodot.ErrForbidden().WithReason("Unable to log out because the logout token in the URL query does not match the session cookie.")))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.d.SessionManager().PurgeFromRequest(r.Context(), w, r); err != nil {
|
||||
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
trace.SpanFromContext(r.Context()).AddEvent(events.NewSessionRevoked(r.Context(), sess.ID, sess.IdentityID))
|
||||
|
||||
h.completeLogout(w, r)
|
||||
}
|
||||
|
||||
func (h *Handler) completeLogout(w http.ResponseWriter, r *http.Request) {
|
||||
_ = h.d.CSRFHandler().RegenerateToken(w, r)
|
||||
|
||||
ret, err := redir.SecureRedirectTo(r, h.d.Config().SelfServiceFlowLogoutRedirectURL(r.Context()),
|
||||
redir.SecureRedirectUseSourceURL(r.RequestURI),
|
||||
redir.SecureRedirectAllowURLs(h.d.Config().SelfServiceBrowserAllowedReturnToDomains(r.Context())),
|
||||
redir.SecureRedirectAllowSelfServiceURLs(h.d.Config().SelfPublicURL(r.Context())),
|
||||
)
|
||||
if err != nil {
|
||||
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Instruct the browser to clear cookies, storage, and caches for this origin.
|
||||
// We only send the header over HTTPS because browsers ignore Clear-Site-Data
|
||||
// on insecure origins, and gate it behind a config flag (off by default) so we
|
||||
// do not clear data that other applications on the same origin may rely on.
|
||||
if h.d.Config().SelfServiceFlowLogoutClearBrowserData(r.Context()) && x.RequestURL(r).Scheme == "https" {
|
||||
w.Header().Set("Clear-Site-Data", `"cookies", "storage", "cache"`)
|
||||
}
|
||||
|
||||
if x.IsJSONRequest(r) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, ret.String(), http.StatusSeeOther)
|
||||
}
|
||||
74
selfservice/flow/logout/handler_clearsitedata_test.go
Normal file
74
selfservice/flow/logout/handler_clearsitedata_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package logout_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/pkg"
|
||||
"github.com/ory/kratos/pkg/testhelpers"
|
||||
"github.com/ory/kratos/x"
|
||||
)
|
||||
|
||||
func TestLogoutClearSiteData(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conf, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/identity.schema.json")
|
||||
public, _, publicRouter, _ := testhelpers.NewKratosServerWithCSRFAndRouters(t, reg)
|
||||
publicRouter.GET("/session/browser/set", func(w http.ResponseWriter, r *http.Request) {
|
||||
testhelpers.MockSetSession(t, reg, conf)(w, r)
|
||||
})
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceLogoutBrowserDefaultReturnTo, public.URL+"/")
|
||||
|
||||
// Resolve the submit URL and token for a fresh session, then issue the logout
|
||||
// without following the redirect so we can inspect the 303 response headers.
|
||||
logout := func(t *testing.T, forwardedProto string) *http.Response {
|
||||
hc := testhelpers.NewSessionClient(t, public.URL+"/session/browser/set")
|
||||
|
||||
u, err := url.Parse(public.URL + "/self-service/logout/browser")
|
||||
require.NoError(t, err)
|
||||
body, res := testhelpers.HTTPRequestJSON(t, hc, "GET", u.String(), nil)
|
||||
require.EqualValues(t, http.StatusOK, res.StatusCode)
|
||||
logoutURL := gjson.GetBytes(body, "logout_url").String()
|
||||
require.NotEmpty(t, logoutURL)
|
||||
|
||||
hc.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
req, err := http.NewRequest("GET", logoutURL, nil)
|
||||
require.NoError(t, err)
|
||||
if forwardedProto != "" {
|
||||
req.Header.Set("X-Forwarded-Proto", forwardedProto)
|
||||
}
|
||||
out, err := hc.Do(req)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = out.Body.Close() })
|
||||
require.Equal(t, http.StatusSeeOther, out.StatusCode, "%s", x.MustReadAll(out.Body))
|
||||
return out
|
||||
}
|
||||
|
||||
t.Run("case=flag disabled does not set header", func(t *testing.T) {
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceLogoutClearBrowserData, false)
|
||||
res := logout(t, "https")
|
||||
assert.Empty(t, res.Header.Get("Clear-Site-Data"))
|
||||
})
|
||||
|
||||
t.Run("case=flag enabled over https sets header", func(t *testing.T) {
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceLogoutClearBrowserData, true)
|
||||
res := logout(t, "https")
|
||||
assert.Equal(t, `"cookies", "storage", "cache"`, res.Header.Get("Clear-Site-Data"))
|
||||
})
|
||||
|
||||
t.Run("case=flag enabled over http does not set header", func(t *testing.T) {
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceLogoutClearBrowserData, true)
|
||||
res := logout(t, "http")
|
||||
assert.Empty(t, res.Header.Get("Clear-Site-Data"))
|
||||
})
|
||||
}
|
||||
305
selfservice/flow/logout/handler_test.go
Normal file
305
selfservice/flow/logout/handler_test.go
Normal file
@@ -0,0 +1,305 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package logout_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/session"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/pkg"
|
||||
"github.com/ory/kratos/pkg/testhelpers"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/x/urlx"
|
||||
)
|
||||
|
||||
func TestLogout(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conf, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
|
||||
errTS := testhelpers.NewErrorTestServer(t, reg)
|
||||
|
||||
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/identity.schema.json")
|
||||
public, _, publicRouter, _ := testhelpers.NewKratosServerWithCSRFAndRouters(t, reg)
|
||||
|
||||
publicRouter.GET("/session/browser/set", func(writer http.ResponseWriter, request *http.Request) {
|
||||
testhelpers.MockSetSession(t, reg, conf)(writer, request)
|
||||
})
|
||||
publicRouter.GET("/session/browser/get", func(w http.ResponseWriter, r *http.Request) {
|
||||
sess, err := reg.SessionManager().FetchFromRequest(r.Context(), r, session.ExpandEverything, identity.ExpandEverything)
|
||||
if err != nil {
|
||||
reg.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
reg.Writer().Write(w, r, sess)
|
||||
})
|
||||
publicRouter.POST("/csrf/check", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceLogoutBrowserDefaultReturnTo, public.URL+"/session/browser/get")
|
||||
|
||||
t.Run("case=successful logout for API clients", func(t *testing.T) {
|
||||
hc := testhelpers.NewDebugClient(t)
|
||||
|
||||
sess := testhelpers.CreateSession(t, reg)
|
||||
_, res := testhelpers.HTTPRequestJSON(t, hc, "DELETE", public.URL+"/self-service/logout/api", json.RawMessage(`{"session_token": "`+sess.Token+`"}`))
|
||||
assert.Equal(t, http.StatusNoContent, res.StatusCode)
|
||||
|
||||
actual, err := reg.SessionPersister().GetSession(ctx, sess.ID, session.ExpandNothing)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, actual.IsActive())
|
||||
|
||||
// Retrying logout should not fail:
|
||||
_, res = testhelpers.HTTPRequestJSON(t, hc, "DELETE", public.URL+"/self-service/logout/api", json.RawMessage(`{"session_token": "`+sess.Token+`"}`))
|
||||
assert.Equal(t, http.StatusNoContent, res.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("case=unsuccessful logout for API clients because session token is invalid", func(t *testing.T) {
|
||||
hc := testhelpers.NewDebugClient(t)
|
||||
|
||||
_, res := testhelpers.HTTPRequestJSON(t, hc, "DELETE", public.URL+"/self-service/logout/api", json.RawMessage(`{"session_token": "not a valid token"}`))
|
||||
assert.Equal(t, http.StatusForbidden, res.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("case=unsuccessful logout for API clients because session token is missing", func(t *testing.T) {
|
||||
hc := testhelpers.NewDebugClient(t)
|
||||
|
||||
_, res := testhelpers.HTTPRequestJSON(t, hc, "DELETE", public.URL+"/self-service/logout/api", json.RawMessage(`{}`))
|
||||
assert.Equal(t, http.StatusForbidden, res.StatusCode)
|
||||
})
|
||||
|
||||
makeBrowserLogout := func(t *testing.T, hc *http.Client, u string) ([]byte, *http.Response) {
|
||||
res, err := hc.Get(u)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
return x.MustReadAll(res.Body), res
|
||||
}
|
||||
|
||||
getLogoutUrl := func(t *testing.T, params url.Values) (hc *http.Client, logoutUrl string) {
|
||||
hc = testhelpers.NewSessionClient(t, public.URL+"/session/browser/set")
|
||||
|
||||
u, err := url.Parse(public.URL)
|
||||
require.NoError(t, err)
|
||||
u.Path = "/self-service/logout/browser"
|
||||
if params != nil {
|
||||
u.RawQuery = params.Encode()
|
||||
}
|
||||
|
||||
body, res := testhelpers.HTTPRequestJSON(t, hc, "GET", u.String(), nil)
|
||||
assert.EqualValues(t, http.StatusOK, res.StatusCode)
|
||||
|
||||
logoutUrl = gjson.GetBytes(body, "logout_url").String()
|
||||
logoutToken := gjson.GetBytes(body, "logout_token").String()
|
||||
|
||||
logoutUrlParsed, err := url.Parse(logoutUrl)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equalf(t, logoutUrlParsed.Query().Get("token"), logoutToken, "%s", body)
|
||||
assert.Equalf(t, logoutUrlParsed.Path, "/self-service/logout", "%s", body)
|
||||
return
|
||||
}
|
||||
|
||||
t.Run("case=successful logout for browser clients", func(t *testing.T) {
|
||||
ensurePrincipalChange := func(t *testing.T, originalCookies []*http.Cookie) {
|
||||
cj, err := cookiejar.New(nil)
|
||||
require.NoError(t, err)
|
||||
cj.SetCookies(urlx.ParseOrPanic(public.URL), originalCookies)
|
||||
res, err := (&http.Client{Jar: cj, Transport: testhelpers.NewTestTransport(t)}).PostForm(public.URL+"/csrf/check", url.Values{})
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
assert.EqualValues(t, http.StatusForbidden, res.StatusCode)
|
||||
body := x.MustReadAll(res.Body)
|
||||
assert.EqualValues(t, nosurfx.ErrInvalidCSRFToken().ReasonField, gjson.GetBytes(body, "error.reason").String(), "%s", body)
|
||||
}
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
hc, logoutUrl := getLogoutUrl(t, nil)
|
||||
originalCookies := hc.Jar.Cookies(urlx.ParseOrPanic(public.URL))
|
||||
require.Contains(t, fmt.Sprintf("%v", hc.Jar.Cookies(urlx.ParseOrPanic(public.URL))), "ory_kratos_session")
|
||||
|
||||
body, res := makeBrowserLogout(t, hc, logoutUrl)
|
||||
assert.EqualValues(t, public.URL+"/session/browser/get", res.Request.URL.String())
|
||||
require.NotContains(t, fmt.Sprintf("%v", hc.Jar.Cookies(urlx.ParseOrPanic(public.URL))), "ory_kratos_session")
|
||||
|
||||
assert.EqualValues(t, http.StatusUnauthorized, res.StatusCode, "%s", body)
|
||||
assert.EqualValues(t, "No active session was found in this request.", gjson.GetBytes(body, "error.reason").String())
|
||||
|
||||
// Logout means CSRF has also been changed.
|
||||
ensurePrincipalChange(t, originalCookies)
|
||||
})
|
||||
|
||||
t.Run("type=ajax", func(t *testing.T) {
|
||||
hc, logoutUrl := getLogoutUrl(t, nil)
|
||||
originalCookies := hc.Jar.Cookies(urlx.ParseOrPanic(public.URL))
|
||||
require.Contains(t, fmt.Sprintf("%v", hc.Jar.Cookies(urlx.ParseOrPanic(public.URL))), "ory_kratos_session")
|
||||
|
||||
body, res := testhelpers.HTTPRequestJSON(t, hc, "GET", logoutUrl, nil)
|
||||
assert.EqualValues(t, logoutUrl, res.Request.URL.String())
|
||||
|
||||
assert.EqualValues(t, http.StatusNoContent, res.StatusCode, "%s", body)
|
||||
require.NotContains(t, fmt.Sprintf("%v", hc.Jar.Cookies(urlx.ParseOrPanic(public.URL))), "ory_kratos_session")
|
||||
|
||||
// Logout means CSRF has also been changed.
|
||||
ensurePrincipalChange(t, originalCookies)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=calling submission without token but with session", func(t *testing.T) {
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
hc := testhelpers.NewSessionClient(t, public.URL+"/session/browser/set")
|
||||
body, res := makeBrowserLogout(t, hc, public.URL+"/self-service/logout")
|
||||
assert.Contains(t, res.Request.URL.String(), errTS.URL)
|
||||
assert.EqualValues(t, http.StatusOK, res.StatusCode, "%s", body)
|
||||
assert.EqualValues(t, "Please include a token in the URL query.", gjson.GetBytes(body, "reason").String(), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("type=ajax", func(t *testing.T) {
|
||||
hc := testhelpers.NewSessionClient(t, public.URL+"/session/browser/set")
|
||||
body, res := testhelpers.HTTPRequestJSON(t, hc, "GET", public.URL+"/self-service/logout", nil)
|
||||
assert.EqualValues(t, public.URL+"/self-service/logout", res.Request.URL.String())
|
||||
assert.EqualValues(t, http.StatusBadRequest, res.StatusCode, "%s", body)
|
||||
assert.EqualValues(t, "Please include a token in the URL query.", gjson.GetBytes(body, "error.reason").String(), "%s", body)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=calling submission with token but without session", func(t *testing.T) {
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
_, logoutUrl := getLogoutUrl(t, nil)
|
||||
|
||||
body, res := makeBrowserLogout(t, testhelpers.NewTestClient(t), logoutUrl)
|
||||
assert.Contains(t, res.Request.URL.String(), errTS.URL)
|
||||
assert.EqualValues(t, http.StatusOK, res.StatusCode, "%s", body)
|
||||
assert.EqualValues(t, "No active session was found in this request.", gjson.GetBytes(body, "reason").String(), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("type=ajax", func(t *testing.T) {
|
||||
_, logoutUrl := getLogoutUrl(t, nil)
|
||||
|
||||
body, res := testhelpers.HTTPRequestJSON(t, testhelpers.NewTestClient(t), "GET", logoutUrl, nil)
|
||||
assert.EqualValues(t, logoutUrl, res.Request.URL.String())
|
||||
assert.EqualValues(t, http.StatusUnauthorized, res.StatusCode, "%s", body)
|
||||
assert.EqualValues(t, "No active session was found in this request.", gjson.GetBytes(body, "error.reason").String(), "%s", body)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=calling submission with token but with session from another user", func(t *testing.T) {
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
otherUser := testhelpers.NewSessionClient(t, public.URL+"/session/browser/set")
|
||||
_, logoutUrl := getLogoutUrl(t, nil)
|
||||
|
||||
body, res := makeBrowserLogout(t, otherUser, logoutUrl)
|
||||
assert.Contains(t, res.Request.URL.String(), errTS.URL)
|
||||
assert.EqualValues(t, http.StatusOK, res.StatusCode, "%s", body)
|
||||
assert.EqualValues(t, "Unable to log out because the logout token in the URL query does not match the session cookie.", gjson.GetBytes(body, "reason").String(), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("type=ajax", func(t *testing.T) {
|
||||
otherUser := testhelpers.NewSessionClient(t, public.URL+"/session/browser/set")
|
||||
_, logoutUrl := getLogoutUrl(t, nil)
|
||||
|
||||
body, res := testhelpers.HTTPRequestJSON(t, otherUser, "GET", logoutUrl, nil)
|
||||
assert.EqualValues(t, logoutUrl, res.Request.URL.String())
|
||||
assert.EqualValues(t, http.StatusForbidden, res.StatusCode, "%s", body)
|
||||
assert.EqualValues(t, "Unable to log out because the logout token in the URL query does not match the session cookie.", gjson.GetBytes(body, "error.reason").String(), "%s", body)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=calling submission with invalid token", func(t *testing.T) {
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
hc, logoutUrl := getLogoutUrl(t, nil)
|
||||
|
||||
body, res := makeBrowserLogout(t, hc, logoutUrl+"invalid")
|
||||
assert.Contains(t, res.Request.URL.String(), errTS.URL)
|
||||
assert.EqualValues(t, http.StatusOK, res.StatusCode, "%s", body)
|
||||
assert.EqualValues(t, "Unable to log out because the logout token in the URL query does not match the session cookie.", gjson.GetBytes(body, "reason").String(), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("type=ajax", func(t *testing.T) {
|
||||
hc, logoutUrl := getLogoutUrl(t, nil)
|
||||
|
||||
body, res := testhelpers.HTTPRequestJSON(t, hc, "GET", logoutUrl+"invalid", nil)
|
||||
assert.EqualValues(t, logoutUrl+"invalid", res.Request.URL.String())
|
||||
assert.EqualValues(t, http.StatusForbidden, res.StatusCode, "%s", body)
|
||||
assert.EqualValues(t, "Unable to log out because the logout token in the URL query does not match the session cookie.", gjson.GetBytes(body, "error.reason").String(), "%s", body)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=calling browser init without session", func(t *testing.T) {
|
||||
body, res := testhelpers.HTTPRequestJSON(t, testhelpers.NewTestClient(t), "GET", public.URL+"/self-service/logout/browser", nil)
|
||||
assert.EqualValues(t, http.StatusUnauthorized, res.StatusCode)
|
||||
assert.EqualValues(t, "No active session was found in this request.", gjson.GetBytes(body, "error.reason").String(), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("case=init logout through browser does 303 redirect", func(t *testing.T) {
|
||||
// init the logout
|
||||
hc, logoutUrl := getLogoutUrl(t, nil)
|
||||
// prevent the redirect, so we can get check the status code
|
||||
hc.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
// submit the login
|
||||
req, err := http.NewRequest("GET", logoutUrl, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := hc.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
// here we check that the redirect status is 303
|
||||
require.Equal(t, http.StatusSeeOther, res.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("case=init logout with return_to should carry over return_to", func(t *testing.T) {
|
||||
reg.Config().MustSet(context.Background(), config.ViperKeyURLsAllowedReturnToDomains, []string{"https://www.ory.sh"})
|
||||
|
||||
hc, logoutUrl := getLogoutUrl(t, url.Values{"return_to": {"https://www.ory.sh"}})
|
||||
|
||||
logoutUrlParsed, err := url.Parse(logoutUrl)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "https://www.ory.sh", logoutUrlParsed.Query().Get("return_to"))
|
||||
|
||||
hc.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
|
||||
body, res := makeBrowserLogout(t, hc, logoutUrl)
|
||||
assert.EqualValues(t, http.StatusSeeOther, res.StatusCode, "%s", body)
|
||||
assert.EqualValues(t, "https://www.ory.sh", res.Header.Get("Location"))
|
||||
})
|
||||
|
||||
t.Run("case=init logout with return_to should not carry over return_to if not allowed", func(t *testing.T) {
|
||||
hc := testhelpers.NewSessionClient(t, public.URL+"/session/browser/set")
|
||||
|
||||
logoutUrl := public.URL + "/self-service/logout/browser?return_to=https://www.ory.com"
|
||||
|
||||
r, err := http.NewRequest("GET", logoutUrl, nil)
|
||||
require.NoError(t, err)
|
||||
r.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := hc.Do(r)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.EqualValues(t, "Requested return_to URL \"https://www.ory.com\" is not allowed.", gjson.GetBytes(body, "error.reason").String(), "%s", body)
|
||||
assert.EqualValues(t, http.StatusBadRequest, resp.StatusCode, "%s", body)
|
||||
})
|
||||
}
|
||||
16
selfservice/flow/logout/stub/identity.schema.json
Normal file
16
selfservice/flow/logout/stub/identity.schema.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$id": "https://example.com/registration.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bar": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user