Files
upstream-ory-kratos/session/handler_test.go
upstream-mirror c60cc7ca75
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
snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
2026-09-15 21:19:12 +05:00

1440 lines
50 KiB
Go

// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package session_test
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"sort"
"strconv"
"strings"
"testing"
"time"
"github.com/go-faker/faker/v4"
"github.com/gofrs/uuid"
"github.com/peterhellberg/link"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/kratos/corpx"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/registration"
. "github.com/ory/kratos/session"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/x/configx"
"github.com/ory/x/httprouterx"
"github.com/ory/x/ioutilx"
"github.com/ory/x/pagination/keysetpagination"
"github.com/ory/x/sqlcon"
"github.com/ory/x/sqlxx"
"github.com/ory/x/urlx"
)
func init() {
corpx.RegisterFakes()
}
func send(code int) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(code)
}
}
func TestSessionWhoAmI(t *testing.T) {
t.Parallel()
conf, reg := pkg.NewFastRegistryWithMocks(t)
ts, _, r, _ := testhelpers.NewKratosServerWithCSRFAndRouters(t, reg)
ctx := context.Background()
email := "foo" + uuid.Must(uuid.NewV4()).String() + "@bar.sh"
externalID := x.NewUUID().String()
i := &identity.Identity{
ID: x.NewUUID(),
ExternalID: sqlxx.NullString(externalID),
State: identity.StateActive,
Credentials: map[identity.CredentialsType]identity.Credentials{
identity.CredentialsTypePassword: {
Type: identity.CredentialsTypePassword,
Identifiers: []string{x.NewUUID().String()},
Config: []byte(`{"hashed_password":"$argon2id$v=19$m=32,t=2,p=4$cm94YnRVOW5jZzFzcVE4bQ$MNzk5BtR2vUhrp6qQEjRNw"}`),
},
},
Traits: identity.Traits(`{"email": "` + email + `","baz":"bar","foo":true,"bar":2.5}`),
MetadataAdmin: []byte(`{"admin":"ma"}`),
MetadataPublic: []byte(`{"public":"mp"}`),
RecoveryAddresses: []identity.RecoveryAddress{
{
Value: email,
Via: identity.AddressTypeEmail,
},
},
VerifiableAddresses: []identity.VerifiableAddress{
{
Value: email,
Via: identity.AddressTypeEmail,
},
},
}
h, _ := testhelpers.MockSessionCreateHandlerWithIdentity(t, reg, i)
r.GET("/set", h)
t.Run("case=aal requirements", func(t *testing.T) {
h1, _ := testhelpers.MockSessionCreateHandlerWithIdentityAndAMR(t, reg,
newAAL2Identity(),
[]identity.CredentialsType{identity.CredentialsTypePassword, identity.CredentialsTypeWebAuthn})
r.GET("/set/aal2-aal2", h1)
h2, _ := testhelpers.MockSessionCreateHandlerWithIdentityAndAMR(t, reg,
newAAL2Identity(),
[]identity.CredentialsType{identity.CredentialsTypePassword})
r.GET("/set/aal2-aal1", h2)
h3, _ := testhelpers.MockSessionCreateHandlerWithIdentityAndAMR(t, reg,
newAAL1Identity(),
[]identity.CredentialsType{identity.CredentialsTypePassword})
r.GET("/set/aal1-aal1", h3)
run := func(t *testing.T, endpoint string, kind string, code int) string {
client := testhelpers.NewClientWithCookies(t)
testhelpers.MockHydrateCookieClient(t, client, ts.URL+"/set/"+kind)
res, err := client.Get(ts.URL + endpoint)
require.NoError(t, err)
body := x.MustReadAll(res.Body)
assert.EqualValues(t, code, res.StatusCode)
return string(body)
}
for k, e := range map[string]string{
"whoami": RouteWhoami,
"collection": RouteCollection,
} {
t.Run(fmt.Sprintf("endpoint=%s", k), func(t *testing.T) {
t.Run("case=aal2-aal2", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, config.HighestAvailableAAL)
run(t, e, "aal2-aal2", http.StatusOK)
})
t.Run("case=aal2-aal2", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, "aal1")
run(t, e, "aal2-aal2", http.StatusOK)
})
t.Run("case=aal2-aal1", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, config.HighestAvailableAAL)
body := run(t, e, "aal2-aal1", http.StatusForbidden)
assert.EqualValues(t, NewErrAALNotSatisfied("").Reason(), gjson.Get(body, "error.reason").String(), body)
})
t.Run("case=aal2-aal1", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, "aal1")
run(t, e, "aal2-aal1", http.StatusOK)
})
t.Run("case=aal1-aal1", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, config.HighestAvailableAAL)
run(t, e, "aal1-aal1", http.StatusOK)
})
})
}
})
t.Run("case=http methods", func(t *testing.T) {
run := func(t *testing.T, cacheEnabled bool, maxAge time.Duration) {
conf.MustSet(ctx, config.ViperKeySessionWhoAmICaching, cacheEnabled)
conf.MustSet(ctx, config.ViperKeySessionWhoAmICachingMaxAge, maxAge)
client := testhelpers.NewClientWithCookies(t)
// No cookie yet -> 401
res, err := client.Get(ts.URL + RouteWhoami)
require.NoError(t, err)
testhelpers.AssertNoCSRFCookieInResponse(t, res) // Test that no CSRF cookie is ever set here.
if cacheEnabled {
assert.NotEmpty(t, res.Header.Get("Ory-Session-Cache-For"))
assert.Equal(t, "60", res.Header.Get("Ory-Session-Cache-For"))
} else {
assert.Empty(t, res.Header.Get("Ory-Session-Cache-For"))
}
// Set cookie
reg.CSRFHandler().IgnorePath("/set")
testhelpers.MockHydrateCookieClient(t, client, ts.URL+"/set")
// Cookie set -> 200 (GET)
for _, method := range []string{
"GET",
"POST",
"PUT",
"DELETE",
} {
t.Run("http_method="+method, func(t *testing.T) {
req, err := http.NewRequest(method, ts.URL+RouteWhoami, nil)
require.NoError(t, err)
res, err = client.Do(req)
require.NoError(t, err)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
testhelpers.AssertNoCSRFCookieInResponse(t, res) // Test that no CSRF cookie is ever set here.
assert.EqualValues(t, http.StatusOK, res.StatusCode)
assert.NotEmpty(t, res.Header.Get("X-Kratos-Authenticated-Identity-Id"))
if cacheEnabled {
var expectedSeconds int
if maxAge > 0 {
expectedSeconds = int(maxAge.Seconds())
} else {
expectedSeconds = int(conf.SessionLifespan(ctx).Seconds())
}
assert.InDelta(t, expectedSeconds, x.Must(strconv.Atoi(res.Header.Get("Ory-Session-Cache-For"))), 5)
} else {
assert.Empty(t, res.Header.Get("Ory-Session-Cache-For"))
}
assert.Empty(t, gjson.GetBytes(body, "identity.credentials"))
assert.Equal(t, "mp", gjson.GetBytes(body, "identity.metadata_public.public").String(), "%s", body)
assert.False(t, gjson.GetBytes(body, "identity.metadata_admin").Exists())
assert.NotEmpty(t, gjson.GetBytes(body, "identity.recovery_addresses").String(), "%s", body)
assert.NotEmpty(t, gjson.GetBytes(body, "identity.verifiable_addresses").String(), "%s", body)
assert.Equal(t, externalID, gjson.GetBytes(body, "identity.external_id").String(), "%s", body)
})
}
}
t.Run("cache disabled", func(t *testing.T) {
run(t, false, 0)
})
t.Run("cache enabled", func(t *testing.T) {
run(t, true, 0)
})
t.Run("cache enabled with max age", func(t *testing.T) {
run(t, true, time.Minute)
})
})
t.Run("tokenize", func(t *testing.T) {
conf.MustSet(t.Context(), config.ViperKeySessionTokenizerTemplates+".es256", &config.SessionTokenizeFormat{
TTL: time.Minute,
JWKSURL: "file://stub/jwk.es256.json",
})
conf.MustSet(ctx, config.ViperKeySessionWhoAmICaching, true)
h3, _ := testhelpers.MockSessionCreateHandlerWithIdentityAndAMR(t, reg, newAAL1Identity(), []identity.CredentialsType{identity.CredentialsTypePassword})
r.GET("/set/tokenize", h3)
client := testhelpers.NewClientWithCookies(t)
testhelpers.MockHydrateCookieClient(t, client, ts.URL+"/set/"+"tokenize")
res, err := client.Get(ts.URL + RouteWhoami + "?tokenize_as=es256")
require.NoError(t, err)
body := x.MustReadAll(res.Body)
assert.EqualValues(t, http.StatusOK, res.StatusCode, string(body))
token := gjson.GetBytes(body, "tokenized").String()
require.NotEmpty(t, token)
segments := strings.Split(token, ".")
require.Len(t, segments, 3, token)
decoded, err := base64.RawURLEncoding.DecodeString(segments[1])
require.NoError(t, err)
assert.NotEmpty(t, gjson.GetBytes(decoded, "sub").Str, decoded)
assert.Empty(t, res.Header.Get("Ory-Session-Cache-For"))
})
/*
t.Run("case=respects AAL config", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySessionLifespan, "1m")
t.Run("required_aal=aal1", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, "aal1")
i := identity.Identity{Traits: []byte("{}"), State: identity.StateActive}
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), &i))
s, err := testhelpers.NewActiveSession(&i, conf, time.Now(), identity.CredentialsTypePassword)
require.NoError(t, err)
require.NoError(t, reg.SessionPersister().UpsertSession(context.Background(), s))
require.NotEmpty(t, s.Token)
req, err := http.NewPostRequest("GET", pts.URL+"/session/get", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+s.Token)
c := testhelpers.NewTestClient(t)
res, err := c.Do(req)
require.NoError(t, err)
assert.EqualValues(t, http.StatusOK, res.StatusCode)
})
t.Run("required_aal=aal2", func(t *testing.T) {
idAAL2 := identity.Identity{Traits: []byte("{}"), State: identity.StateActive, Credentials: map[identity.CredentialsType]identity.Credentials{
identity.CredentialsTypePassword: {Type: identity.CredentialsTypePassword, Config: []byte("{}")},
identity.CredentialsTypeWebAuthn: {Type: identity.CredentialsTypeWebAuthn, Config: []byte("{}")},
}}
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), &idAAL2))
idAAL1 := identity.Identity{Traits: []byte("{}"), State: identity.StateActive, Credentials: map[identity.CredentialsType]identity.Credentials{
identity.CredentialsTypePassword: {Type: identity.CredentialsTypePassword, Config: []byte("{}")},
}}
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), &idAAL1))
run := func(t *testing.T, complete []identity.CredentialsType, expectedCode int, i *identity.Identity) {
s := session.NewInactiveSession()
for _, m := range complete {
s.CompletedLoginFor(m)
}
require.NoError(t, s.Activate(i, conf, time.Now().UTC()))
require.NoError(t, reg.SessionPersister().UpsertSession(context.Background(), s))
require.NotEmpty(t, s.Token)
req, err := http.NewPostRequest("GET", pts.URL+"/session/get", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+s.Token)
c := testhelpers.NewTestClient(t)
res, err := c.Do(req)
require.NoError(t, err)
assert.EqualValues(t, expectedCode, res.StatusCode)
}
t.Run("fulfilled for aal2 if identity has aal2", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, config.HighestAvailableAAL)
run(t, []identity.CredentialsType{identity.CredentialsTypePassword, identity.CredentialsTypeWebAuthn}, 200, &idAAL2)
})
t.Run("rejected for aal1 if identity has aal2", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, config.HighestAvailableAAL)
run(t, []identity.CredentialsType{identity.CredentialsTypePassword}, 403, &idAAL2)
})
t.Run("fulfilled for aal1 if identity has aal2 but config is aal1", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, "aal1")
run(t, []identity.CredentialsType{identity.CredentialsTypePassword}, 200, &idAAL2)
})
t.Run("fulfilled for aal2 if identity has aal1", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, config.HighestAvailableAAL)
run(t, []identity.CredentialsType{identity.CredentialsTypePassword, identity.CredentialsTypeWebAuthn}, 200, &idAAL1)
})
t.Run("fulfilled for aal1 if identity has aal1", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, config.HighestAvailableAAL)
run(t, []identity.CredentialsType{identity.CredentialsTypePassword}, 200, &idAAL1)
})
})
})
*/
}
func TestIsNotAuthenticatedSecurecookie(t *testing.T) {
t.Parallel()
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
r := httprouterx.NewRouterPublic()
r.GET("/public/with-callback", reg.SessionHandler().IsNotAuthenticated(send(http.StatusOK), send(http.StatusBadRequest)))
ts := httptest.NewServer(r)
defer ts.Close()
conf.MustSet(ctx, config.ViperKeyPublicBaseURL, ts.URL)
c := testhelpers.NewClientWithCookies(t)
c.Jar.SetCookies(urlx.ParseOrPanic(ts.URL), []*http.Cookie{
{
Name: config.DefaultSessionCookieName,
// This is an invalid cookie because it is generated by a very random secret
Value: "MTU3Mjg4Njg0MXxEdi1CQkFFQ180SUFBUkFCRUFBQU52LUNBQUVHYzNSeWFXNW5EQVVBQTNOcFpBWnpkSEpwYm1jTUd3QVpUWFZXVUhSQlZVeExXRWRUUmxkVVoyUkpUVXhzY201SFNBPT187kdI3dMP-ep389egDR2TajYXGG-6xqC2mAlgnBi0vsg=",
HttpOnly: true,
Path: "/",
Expires: time.Now().Add(time.Hour),
},
})
res, err := c.Get(ts.URL + "/public/with-callback")
require.NoError(t, err)
assert.EqualValues(t, http.StatusOK, res.StatusCode)
}
func TestIsNotAuthenticated(t *testing.T) {
t.Parallel()
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
r := httprouterx.NewRouterPublic()
reg.WithCSRFHandler(new(nosurfx.FakeCSRFHandler))
h, _ := testhelpers.MockSessionCreateHandler(t, reg)
r.GET("/set", h)
r.GET("/public/with-callback", reg.SessionHandler().IsNotAuthenticated(send(http.StatusOK), send(http.StatusBadRequest)))
r.GET("/public/without-callback", reg.SessionHandler().IsNotAuthenticated(send(http.StatusOK), nil))
ts := httptest.NewServer(r)
defer ts.Close()
conf.MustSet(ctx, config.ViperKeyPublicBaseURL, ts.URL)
sessionClient := testhelpers.NewClientWithCookies(t)
testhelpers.MockHydrateCookieClient(t, sessionClient, ts.URL+"/set")
for k, tc := range []struct {
c *http.Client
call string
code int
}{
{
c: sessionClient,
call: "/public/with-callback",
code: http.StatusBadRequest,
},
{
c: testhelpers.NewTestClient(t),
call: "/public/with-callback",
code: http.StatusOK,
},
{
c: sessionClient,
call: "/public/without-callback",
code: http.StatusForbidden,
},
{
c: testhelpers.NewTestClient(t),
call: "/public/without-callback",
code: http.StatusOK,
},
} {
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
res, err := tc.c.Get(ts.URL + tc.call)
require.NoError(t, err)
assert.EqualValues(t, tc.code, res.StatusCode)
})
}
}
func TestIsAuthenticated(t *testing.T) {
t.Parallel()
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
reg.WithCSRFHandler(new(nosurfx.FakeCSRFHandler))
r := httprouterx.NewRouterPublic()
h, _ := testhelpers.MockSessionCreateHandler(t, reg)
r.GET("/set", h)
r.GET("/privileged/with-callback", reg.SessionHandler().IsAuthenticated(send(http.StatusOK), send(http.StatusBadRequest)))
r.GET("/privileged/without-callback", reg.SessionHandler().IsAuthenticated(send(http.StatusOK), nil))
ts := httptest.NewServer(r)
defer ts.Close()
conf.MustSet(ctx, config.ViperKeyPublicBaseURL, ts.URL)
sessionClient := testhelpers.NewClientWithCookies(t)
testhelpers.MockHydrateCookieClient(t, sessionClient, ts.URL+"/set")
for k, tc := range []struct {
c *http.Client
call string
code int
}{
{
c: sessionClient,
call: "/privileged/with-callback",
code: http.StatusOK,
},
{
c: testhelpers.NewTestClient(t),
call: "/privileged/with-callback",
code: http.StatusBadRequest,
},
{
c: sessionClient,
call: "/privileged/without-callback",
code: http.StatusOK,
},
{
c: testhelpers.NewTestClient(t),
call: "/privileged/without-callback",
code: http.StatusUnauthorized,
},
} {
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
res, err := tc.c.Get(ts.URL + tc.call)
require.NoError(t, err)
assert.EqualValues(t, tc.code, res.StatusCode)
})
}
}
func TestHandlerAdminSessionManagement(t *testing.T) {
t.Parallel()
_, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/identity.schema.json")))
public, ts := testhelpers.NewKratosServer(t, reg)
t.Run("case=should return 202 after invalidating all sessions", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
var s *Session
require.NoError(t, faker.FakeData(&s))
s.Active = true
s.AMR = AuthenticationMethods{
{Method: identity.CredentialsTypePassword, CompletedAt: time.Now().UTC().Round(time.Second)},
{Method: identity.CredentialsTypeOIDC, CompletedAt: time.Now().UTC().Round(time.Second)},
}
s.Identity.MetadataAdmin = []byte(`{"admin":"data"}`)
require.NoError(t, reg.Persister().CreateIdentity(t.Context(), s.Identity))
var expectedSessionDevice Device
require.NoError(t, faker.FakeData(&expectedSessionDevice))
s.Devices = []Device{
expectedSessionDevice,
}
assert.Zero(t, s.ID)
require.NoError(t, reg.SessionPersister().UpsertSession(t.Context(), s))
assert.NotZero(t, s.ID)
assert.NotZero(t, s.Identity.ID)
t.Run("get session", func(t *testing.T) {
req, _ := http.NewRequest("GET", ts.URL+"/admin/sessions/"+s.ID.String(), nil)
res, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
var session Session
require.NoError(t, json.NewDecoder(res.Body).Decode(&session))
assert.Equal(t, s.ID, session.ID)
assert.Nil(t, session.Identity)
assert.Empty(t, session.Devices)
})
t.Run("get session expand", func(t *testing.T) {
for _, tc := range []struct {
description string
expand string
expectedIdentityId string
expectedDevices int
}{
{
description: "expand Identity",
expand: "?expand=Identity",
expectedIdentityId: s.Identity.ID.String(),
expectedDevices: 0,
},
{
description: "expand Devices",
expand: "/?expand=Devices",
expectedIdentityId: "",
expectedDevices: 1,
},
{
description: "expand Identity and Devices",
expand: "/?expand=Identity&expand=Devices",
expectedIdentityId: s.Identity.ID.String(),
expectedDevices: 1,
},
} {
t.Run(fmt.Sprintf("description=%s", tc.description), func(t *testing.T) {
req, _ := http.NewRequest("GET", ts.URL+"/admin/sessions/"+s.ID.String()+tc.expand, nil)
res, err := client.Do(req)
require.NoError(t, err)
body := ioutilx.MustReadAll(res.Body)
require.Equalf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.Equal(t, s.ID.String(), gjson.GetBytes(body, "id").String())
assert.Equal(t, tc.expectedIdentityId, gjson.GetBytes(body, "identity.id").String())
assert.EqualValuesf(t, tc.expectedDevices, gjson.GetBytes(body, "devices.#").Int(), "%s", gjson.GetBytes(body, "devices").Raw)
if tc.expectedIdentityId != "" {
assert.True(t, gjson.GetBytes(body, "identity.metadata_admin").Exists(), "expected metadata_admin to be present in admin session response, body: %s", body)
}
})
}
})
t.Run("get session expand invalid", func(t *testing.T) {
req, _ := http.NewRequest("GET", ts.URL+"/admin/sessions/"+s.ID.String()+"/?expand=invalid", nil)
res, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
})
t.Run("should redirect to public for whoami", func(t *testing.T) {
client := testhelpers.NewHTTPClientWithSessionToken(t.Context(), t, reg, s)
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
req := testhelpers.NewTestHTTPRequest(t, "GET", ts.URL+"/admin/sessions/whoami", nil)
res, err := client.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusTemporaryRedirect, res.StatusCode)
require.Equal(t, public.URL+"/sessions/whoami", res.Header.Get("Location"))
})
assertPageToken := func(t *testing.T, id, linkHeader string) {
t.Helper()
g := link.Parse(linkHeader)
require.Len(t, g, 1)
u, err := url.Parse(g["first"].URI)
require.NoError(t, err)
pt, err := keysetpagination.NewMapPageToken(u.Query().Get("page_token"))
require.NoError(t, err)
mpt := pt.(keysetpagination.MapPageToken)
assert.Equal(t, id, mpt["id"])
}
t.Run("list sessions", func(t *testing.T) {
req, _ := http.NewRequest("GET", ts.URL+"/admin/sessions/", nil)
res, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
assertPageToken(t, uuid.Nil.String(), res.Header.Get("Link"))
var sessions []Session
require.NoError(t, json.NewDecoder(res.Body).Decode(&sessions))
require.Len(t, sessions, 1)
assert.Equal(t, s.ID, sessions[0].ID)
assert.Empty(t, sessions[0].Identity)
assert.Empty(t, sessions[0].Devices)
})
t.Run("list sessions expand", func(t *testing.T) {
for _, tc := range []struct {
description string
expand string
expectedIdentityId string
expectedDevicesCount string
}{
{
description: "expand nothing",
expand: "",
expectedIdentityId: "",
expectedDevicesCount: "",
},
{
description: "expand Identity",
expand: "expand=identity&",
expectedIdentityId: s.Identity.ID.String(),
expectedDevicesCount: "",
},
{
description: "expand Devices",
expand: "expand=devices&",
expectedIdentityId: "",
expectedDevicesCount: "1",
},
{
description: "expand Identity and Devices",
expand: "expand=identity&expand=devices&",
expectedIdentityId: s.Identity.ID.String(),
expectedDevicesCount: "1",
},
} {
t.Run(fmt.Sprintf("description=%s", tc.description), func(t *testing.T) {
req, _ := http.NewRequest("GET", ts.URL+"/admin/sessions?"+tc.expand, nil)
res, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
assertPageToken(t, uuid.Nil.String(), res.Header.Get("Link"))
body := ioutilx.MustReadAll(res.Body)
assert.Equal(t, s.ID.String(), gjson.GetBytes(body, "0.id").String())
assert.Equal(t, tc.expectedIdentityId, gjson.GetBytes(body, "0.identity.id").String())
assert.Equal(t, tc.expectedDevicesCount, gjson.GetBytes(body, "0.devices.#").String())
if tc.expectedIdentityId != "" {
assert.True(t, gjson.GetBytes(body, "0.identity.metadata_admin").Exists(), "expected metadata_admin to be present in admin session response, body: %s", body)
}
})
}
})
t.Run("should list sessions for an identity", func(t *testing.T) {
req, _ := http.NewRequest("GET", ts.URL+"/admin/identities/"+s.Identity.ID.String()+"/sessions", nil)
res, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
body := ioutilx.MustReadAll(res.Body)
require.Equal(t, 1, int(gjson.GetBytes(body, "#").Int()))
assert.Equal(t, s.ID.String(), gjson.GetBytes(body, "0.id").String())
assert.True(t, gjson.GetBytes(body, "0.identity.metadata_admin").Exists(), "expected metadata_admin to be present in admin identity sessions response, body: %s", body)
})
t.Run("should revoke session by id", func(t *testing.T) {
req, _ := http.NewRequest("GET", ts.URL+"/admin/sessions/"+s.ID.String(), nil)
res, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
var session Session
require.NoError(t, json.NewDecoder(res.Body).Decode(&session))
assert.Equal(t, s.ID, session.ID)
assert.True(t, session.Active)
req, _ = http.NewRequest("DELETE", ts.URL+"/admin/sessions/"+s.ID.String(), nil)
res, err = client.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusNoContent, res.StatusCode)
req, _ = http.NewRequest("GET", ts.URL+"/admin/sessions/"+s.ID.String(), nil)
res, err = client.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
require.NoError(t, json.NewDecoder(res.Body).Decode(&session))
assert.Equal(t, s.ID, session.ID)
assert.False(t, session.Active)
})
t.Run("case=session status should be false when session expiry is past", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
s.ExpiresAt = time.Now().Add(-time.Hour * 1)
require.NoError(t, reg.SessionPersister().UpsertSession(t.Context(), s))
assert.NotEqual(t, uuid.Nil, s.ID)
assert.NotEqual(t, uuid.Nil, s.Identity.ID)
req, _ := http.NewRequest("GET", ts.URL+"/admin/sessions/"+s.ID.String(), nil)
res, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.Equal(t, "false", gjson.GetBytes(body, "active").String(), "%s", body)
})
t.Run("case=session status should be false for inactive identity", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
var s1 *Session
require.NoError(t, faker.FakeData(&s1))
s1.Active = true
s1.Identity.State = identity.StateInactive
require.NoError(t, reg.Persister().CreateIdentity(t.Context(), s1.Identity))
assert.Equal(t, uuid.Nil, s1.ID)
require.NoError(t, reg.SessionPersister().UpsertSession(t.Context(), s1))
assert.NotEqual(t, uuid.Nil, s1.ID)
assert.NotEqual(t, uuid.Nil, s1.Identity.ID)
req, _ := http.NewRequest("GET", ts.URL+"/admin/sessions/"+s1.ID.String()+"?expand=Identity", nil)
res, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.Equal(t, "false", gjson.GetBytes(body, "active").String(), "%s", body)
})
req, _ := http.NewRequest("DELETE", ts.URL+"/admin/identities/"+s.Identity.ID.String()+"/sessions", nil)
res, err := client.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusNoContent, res.StatusCode)
_, err = reg.SessionPersister().GetSession(t.Context(), s.ID, ExpandNothing)
require.True(t, errors.Is(err, sqlcon.ErrNoRows()))
t.Run("should not list session", func(t *testing.T) {
req, _ := http.NewRequest("GET", ts.URL+"/admin/identities/"+s.Identity.ID.String()+"/sessions", nil)
res, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.JSONEq(t, "[]", string(ioutilx.MustReadAll(res.Body)))
})
})
t.Run("case=should return 400 when bad UUID is sent", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
for _, method := range []string{http.MethodGet, http.MethodDelete} {
t.Run("http method="+method, func(t *testing.T) {
req, _ := http.NewRequest(method, ts.URL+"/admin/identities/BADUUID/sessions", nil)
res, err := client.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusBadRequest, res.StatusCode)
})
}
})
t.Run("case=should return 404 when deleting with unknown UUID", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
someID, _ := uuid.NewV4()
req, _ := http.NewRequest("DELETE", ts.URL+"/admin/identities/"+someID.String()+"/sessions", nil)
res, err := client.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusNotFound, res.StatusCode)
})
t.Run("case=should return pagination headers on list response", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
var i *identity.Identity
require.NoError(t, faker.FakeData(&i))
require.NoError(t, reg.Persister().CreateIdentity(t.Context(), i))
numSessions := 5
numSessionsActive := 2
sess := make([]Session, numSessions)
for j := range sess {
require.NoError(t, faker.FakeData(&sess[j]))
sess[j].Identity = i
if j < numSessionsActive {
sess[j].Active = true
sess[j].ExpiresAt = time.Now().UTC().Add(time.Hour)
} else {
sess[j].Active = false
sess[j].ExpiresAt = time.Now().UTC().Add(-time.Hour)
}
require.NoError(t, reg.SessionPersister().UpsertSession(t.Context(), &sess[j]))
}
for _, tc := range []struct {
activeOnly string
expectedSessionIds []uuid.UUID
}{
{
activeOnly: "true",
expectedSessionIds: []uuid.UUID{sess[0].ID, sess[1].ID},
},
{
activeOnly: "false",
expectedSessionIds: []uuid.UUID{sess[2].ID, sess[3].ID, sess[4].ID},
},
{
activeOnly: "",
expectedSessionIds: []uuid.UUID{sess[0].ID, sess[1].ID, sess[2].ID, sess[3].ID, sess[4].ID},
},
} {
t.Run(fmt.Sprintf("active=%#v", tc.activeOnly), func(t *testing.T) {
sessions, _, err := reg.SessionPersister().ListSessionsByIdentity(t.Context(), i.ID, nil, 1, 10, uuid.Nil, ExpandEverything)
require.NoError(t, err)
require.Len(t, sessions, 5)
assert.True(t, sort.IsSorted(sort.Reverse(byCreatedAt(sessions))))
reqURL := ts.URL + "/admin/identities/" + i.ID.String() + "/sessions"
if tc.activeOnly != "" {
reqURL += "?active=" + tc.activeOnly
}
req, _ := http.NewRequest("GET", reqURL, nil)
res, err := client.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode)
var actualSessions []Session
require.NoError(t, json.NewDecoder(res.Body).Decode(&actualSessions))
actualSessionIds := make([]uuid.UUID, 0)
for _, s := range actualSessions {
actualSessionIds = append(actualSessionIds, s.ID)
}
assert.NotEqual(t, "", res.Header.Get("Link"))
assert.ElementsMatch(t, tc.expectedSessionIds, actualSessionIds)
})
}
})
}
func TestHandlerManageSessions(t *testing.T) {
t.Parallel()
_, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/identity.schema.json")))
_, ts := testhelpers.NewKratosServer(t, reg)
client := testhelpers.NewClientWithCookies(t)
makeIdentityWithSession := func(t *testing.T) (*identity.Identity, *Session) {
var s *Session
require.NoError(t, faker.FakeData(&s))
s.Active = true
require.NoError(t, reg.Persister().CreateIdentity(t.Context(), s.Identity))
require.NoError(t, reg.SessionPersister().UpsertSession(t.Context(), s))
return s.Identity, s
}
post := func(t *testing.T, body string) *http.Response {
req, err := http.NewRequest("POST", ts.URL+"/admin/sessions", strings.NewReader(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
res, err := client.Do(req)
require.NoError(t, err)
t.Cleanup(func() { _ = res.Body.Close() })
return res
}
decode := func(t *testing.T, res *http.Response) ManageSessionsResponse {
t.Helper()
var resp ManageSessionsResponse
require.NoError(t, json.NewDecoder(res.Body).Decode(&resp))
return resp
}
sessionActive := func(t *testing.T, sID uuid.UUID) bool {
s, err := reg.SessionPersister().GetSession(t.Context(), sID, ExpandNothing)
require.NoError(t, err)
return s.Active
}
sessionExists := func(t *testing.T, sID uuid.UUID) bool {
_, err := reg.SessionPersister().GetSession(t.Context(), sID, ExpandNothing)
if errors.Is(err, sqlcon.ErrNoRows()) {
return false
}
require.NoError(t, err)
return true
}
t.Run("case=disable by identities", func(t *testing.T) {
i, s := makeIdentityWithSession(t)
body := fmt.Sprintf(`{"action":"disable","identities":["%s"]}`, i.ID)
res := post(t, body)
require.Equal(t, http.StatusOK, res.StatusCode)
resp := decode(t, res)
assert.Equal(t, 1, resp.Processed)
assert.False(t, resp.More)
assert.False(t, sessionActive(t, s.ID))
})
t.Run("case=disable by sessions", func(t *testing.T) {
_, s := makeIdentityWithSession(t)
body := fmt.Sprintf(`{"action":"disable","sessions":["%s"]}`, s.ID)
res := post(t, body)
require.Equal(t, http.StatusOK, res.StatusCode)
resp := decode(t, res)
assert.Equal(t, 1, resp.Processed)
assert.False(t, resp.More)
assert.False(t, sessionActive(t, s.ID))
})
t.Run("case=delete by identities", func(t *testing.T) {
i, s := makeIdentityWithSession(t)
body := fmt.Sprintf(`{"action":"delete","identities":["%s"]}`, i.ID)
res := post(t, body)
require.Equal(t, http.StatusOK, res.StatusCode)
resp := decode(t, res)
assert.Equal(t, 1, resp.Processed)
assert.False(t, resp.More)
assert.False(t, sessionExists(t, s.ID))
})
t.Run("case=delete by sessions", func(t *testing.T) {
_, s := makeIdentityWithSession(t)
body := fmt.Sprintf(`{"action":"delete","sessions":["%s"]}`, s.ID)
res := post(t, body)
require.Equal(t, http.StatusOK, res.StatusCode)
resp := decode(t, res)
assert.Equal(t, 1, resp.Processed)
assert.False(t, resp.More)
assert.False(t, sessionExists(t, s.ID))
})
t.Run("case=invalid action", func(t *testing.T) {
res := post(t, `{"action":"flarble","identities":["00000000-0000-0000-0000-000000000001"]}`)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
})
t.Run("case=both filters set", func(t *testing.T) {
res := post(t, `{"action":"disable","identities":["00000000-0000-0000-0000-000000000001"],"sessions":["00000000-0000-0000-0000-000000000002"]}`)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
})
t.Run("case=neither filter set", func(t *testing.T) {
res := post(t, `{"action":"disable"}`)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
})
t.Run("case=empty array", func(t *testing.T) {
res := post(t, `{"action":"disable","identities":[]}`)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
})
t.Run("case=malformed UUID", func(t *testing.T) {
res := post(t, `{"action":"disable","identities":["not-a-uuid"]}`)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
})
t.Run("case=wildcard mixed with explicit id rejected", func(t *testing.T) {
res := post(t, `{"action":"disable","identities":["*","00000000-0000-0000-0000-000000000001"]}`)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
})
t.Run("case=wildcard via sessions field rejected (disable)", func(t *testing.T) {
res := post(t, `{"action":"disable","sessions":["*"]}`)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
})
t.Run("case=wildcard via sessions field rejected (delete)", func(t *testing.T) {
res := post(t, `{"action":"delete","sessions":["*"]}`)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
})
t.Run("case=oversize batch rejected", func(t *testing.T) {
ids := make([]string, ManageSessionsMaxIDs+1)
for i := range ids {
ids[i] = uuid.Must(uuid.NewV4()).String()
}
body := fmt.Sprintf(`{"action":"disable","identities":[%q`, ids[0])
for _, id := range ids[1:] {
body += "," + fmt.Sprintf("%q", id)
}
body += `]}`
res := post(t, body)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
})
t.Run("case=non-existent identity is silent no-op", func(t *testing.T) {
ghost := uuid.Must(uuid.NewV4()).String()
res := post(t, fmt.Sprintf(`{"action":"disable","identities":[%q]}`, ghost))
require.Equal(t, http.StatusOK, res.StatusCode)
resp := decode(t, res)
assert.Equal(t, 0, resp.Processed)
assert.False(t, resp.More)
})
}
// TestHandlerManageSessionsWildcard exercises the `["*"]` wildcard variants
// of the manage-sessions endpoint. Each subtest disables or deletes every
// session in the network, so they are isolated to their own registry to avoid
// contaminating other tests that share state.
func TestHandlerManageSessionsWildcard(t *testing.T) {
t.Parallel()
cases := []struct {
name string
body string
assertAfter func(t *testing.T, persister Persister, ids []uuid.UUID)
}{
{
name: "case=wildcard disable disables all sessions in network",
body: `{"action":"disable","identities":["*"]}`,
assertAfter: func(t *testing.T, persister Persister, ids []uuid.UUID) {
for _, id := range ids {
s, err := persister.GetSession(t.Context(), id, ExpandNothing)
require.NoError(t, err)
assert.False(t, s.Active, "session %s must be inactive", id)
}
},
},
{
name: "case=wildcard delete deletes all sessions in network",
body: `{"action":"delete","identities":["*"]}`,
assertAfter: func(t *testing.T, persister Persister, ids []uuid.UUID) {
for _, id := range ids {
_, err := persister.GetSession(t.Context(), id, ExpandNothing)
assert.ErrorIs(t, err, sqlcon.ErrNoRows(), "session %s must be deleted", id)
}
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/identity.schema.json")))
_, ts := testhelpers.NewKratosServer(t, reg)
client := testhelpers.NewClientWithCookies(t)
ids := make([]uuid.UUID, 0, 2)
for range 2 {
var s *Session
require.NoError(t, faker.FakeData(&s))
s.Active = true
require.NoError(t, reg.Persister().CreateIdentity(t.Context(), s.Identity))
require.NoError(t, reg.SessionPersister().UpsertSession(t.Context(), s))
ids = append(ids, s.ID)
}
req, err := http.NewRequest("POST", ts.URL+"/admin/sessions", strings.NewReader(tc.body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
res, err := client.Do(req)
require.NoError(t, err)
t.Cleanup(func() { _ = res.Body.Close() })
require.Equal(t, http.StatusOK, res.StatusCode, "wildcard requests return 200 with a body")
var resp ManageSessionsResponse
require.NoError(t, json.NewDecoder(res.Body).Decode(&resp))
assert.Equal(t, 2, resp.Processed, "the two seeded sessions are processed in the single batch")
assert.False(t, resp.More, "two sessions fit into one batch; nothing more to drain")
tc.assertAfter(t, reg.SessionPersister(), ids)
})
}
}
func TestHandlerSelfServiceSessionManagement(t *testing.T) {
t.Parallel()
_, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/identity.schema.json")),
)
ts, _, r, _ := testhelpers.NewKratosServerWithCSRFAndRouters(t, reg)
var setup func(t *testing.T) (*http.Client, *identity.Identity, *Session)
{
// we limit the scope of the channels, so you cannot accidentally mess up a test case
ident := make(chan *identity.Identity, 1)
sess := make(chan *Session, 1)
r.GET("/set", func(w http.ResponseWriter, r *http.Request) {
h, s := testhelpers.MockSessionCreateHandlerWithIdentity(t, reg, <-ident)
h(w, r)
sess <- s
})
setup = func(t *testing.T) (*http.Client, *identity.Identity, *Session) {
client := testhelpers.NewClientWithCookies(t)
i := identity.NewIdentity("") // the identity is created by the handler
ident <- i
testhelpers.MockHydrateCookieClient(t, client, ts.URL+"/set")
return client, i, <-sess
}
}
t.Run("case=list should return pagination headers", func(t *testing.T) {
client, i, _ := setup(t)
numSessions := 5
numSessionsActive := 2
sess := make([]Session, numSessions)
for j := range sess {
require.NoError(t, faker.FakeData(&sess[j]))
sess[j].Identity = i
if j < numSessionsActive {
sess[j].Active = true
} else {
sess[j].Active = false
}
require.NoError(t, reg.SessionPersister().UpsertSession(t.Context(), &sess[j]))
}
reqURL := ts.URL + "/sessions"
req, _ := http.NewRequest("GET", reqURL, nil)
res, err := client.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode)
require.NotEqual(t, "", res.Header.Get("Link"))
})
t.Run("case=should return 200 and number after invalidating all other sessions", func(t *testing.T) {
client, i, currSess := setup(t)
otherSess := Session{}
require.NoError(t, faker.FakeData(&otherSess))
otherSess.Identity = i
otherSess.Active = true
require.NoError(t, reg.SessionPersister().UpsertSession(t.Context(), &otherSess))
req, _ := http.NewRequest("DELETE", ts.URL+"/sessions", nil)
res, err := client.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.Equal(t, int64(1), gjson.GetBytes(body, "count").Int(), "%s", body)
actualOther, err := reg.SessionPersister().GetSession(t.Context(), otherSess.ID, ExpandNothing)
require.NoError(t, err)
assert.False(t, actualOther.Active)
actualCurr, err := reg.SessionPersister().GetSession(t.Context(), currSess.ID, ExpandNothing)
require.NoError(t, err)
assert.True(t, actualCurr.Active)
})
t.Run("case=should revoke specific other session", func(t *testing.T) {
client, i, _ := setup(t)
others := make([]Session, 2)
for j := range others {
require.NoError(t, faker.FakeData(&others[j]))
others[j].Identity = i
others[j].Active = true
require.NoError(t, reg.SessionPersister().UpsertSession(t.Context(), &others[j]))
}
req, _ := http.NewRequest("DELETE", ts.URL+"/sessions/"+others[0].ID.String(), nil)
res, err := client.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusNoContent, res.StatusCode)
actualOthers, total, err := reg.SessionPersister().ListSessionsByIdentity(t.Context(), i.ID, nil, 1, 10, uuid.Nil, ExpandNothing)
require.NoError(t, err)
require.Len(t, actualOthers, 3)
require.EqualValues(t, 3, total)
for _, s := range actualOthers {
if s.ID == others[0].ID {
assert.False(t, s.Active)
} else {
assert.True(t, s.Active)
}
}
})
t.Run("case=should not revoke current session", func(t *testing.T) {
client, _, currSess := setup(t)
req, _ := http.NewRequest("DELETE", ts.URL+"/sessions/"+currSess.ID.String(), nil)
resp, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("case=should not error on unknown or revoked session", func(t *testing.T) {
client, i, _ := setup(t)
otherSess := Session{}
require.NoError(t, faker.FakeData(&otherSess))
otherSess.Identity = i
otherSess.Active = false
require.NoError(t, reg.SessionPersister().UpsertSession(t.Context(), &otherSess))
for j, id := range []uuid.UUID{otherSess.ID, uuid.Must(uuid.NewV4())} {
req, _ := http.NewRequest("DELETE", ts.URL+"/sessions/"+id.String(), nil)
resp, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusNoContent, resp.StatusCode, "case=%d", j)
}
})
t.Run("case=whoami should not issue cookie for up to date session", func(t *testing.T) {
client, _, _ := setup(t)
req, _ := http.NewRequest("GET", ts.URL+"/sessions/whoami", nil)
resp, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Empty(t, resp.Cookies())
})
t.Run("case=whoami should reissue cookie for outdated session", func(t *testing.T) {
client, _, session := setup(t)
oldExpires := session.ExpiresAt
session.ExpiresAt = time.Now().Add(time.Hour * 24 * 30).UTC().Round(time.Hour)
err := reg.SessionPersister().UpsertSession(context.Background(), session)
require.NoError(t, err)
resp, err := client.Get(ts.URL + "/sessions/whoami")
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
require.Len(t, resp.Cookies(), 1)
for _, c := range resp.Cookies() {
assert.WithinDuration(t, session.ExpiresAt, c.Expires, 5*time.Second, "Ensure the expiry does not deviate +- 5 seconds from the expiry of the session for cookie: %s", c.Name)
assert.NotEqual(t, oldExpires, c.Expires, "%s", c.Name)
}
})
t.Run("case=whoami should not issue cookie if request is token based", func(t *testing.T) {
_, _, session := setup(t)
session.ExpiresAt = time.Now().Add(time.Hour * 24 * 30).UTC().Round(time.Hour)
err := reg.SessionPersister().UpsertSession(context.Background(), session)
require.NoError(t, err)
req, err := http.NewRequest("GET", ts.URL+"/sessions/whoami", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+session.Token)
resp, err := testhelpers.NewTestClient(t).Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
require.Len(t, resp.Cookies(), 0)
})
}
func TestHandlerRefreshSessionBySessionID(t *testing.T) {
t.Parallel()
_, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/identity.schema.json")),
)
publicServer, adminServer, _, _ := testhelpers.NewKratosServerWithCSRFAndRouters(t, reg)
i := identity.NewIdentity("")
require.NoError(t, reg.IdentityManager().Create(context.Background(), i))
s := &Session{Identity: i, ExpiresAt: time.Now().Add(5 * time.Minute)}
require.NoError(t, reg.SessionPersister().UpsertSession(context.Background(), s))
t.Run("case=should return 200 after refreshing one session", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
req, _ := http.NewRequest("PATCH", adminServer.URL+"/admin/sessions/"+s.ID.String()+"/extend", nil)
res, err := client.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode)
updatedSession, err := reg.SessionPersister().GetSession(context.Background(), s.ID, ExpandNothing)
require.Nil(t, err)
require.True(t, s.ExpiresAt.Before(updatedSession.ExpiresAt))
})
t.Run("case=should return 400 when bad UUID is sent", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
req, _ := http.NewRequest("PATCH", adminServer.URL+"/admin/sessions/BADUUID/extend", nil)
res, err := client.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusBadRequest, res.StatusCode)
})
t.Run("case=should return 404 when calling with missing UUID", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
someID, _ := uuid.NewV4()
req, _ := http.NewRequest("PATCH", adminServer.URL+"/admin/sessions/"+someID.String()+"/extend", nil)
res, err := client.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusNotFound, res.StatusCode)
})
t.Run("case=should return 404 when calling puplic server", func(t *testing.T) {
req := testhelpers.NewTestHTTPRequest(t, "PATCH", publicServer.URL+"/sessions/"+s.ID.String()+"/extend", nil)
res, err := publicServer.Client().Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, res.StatusCode)
body := ioutilx.MustReadAll(res.Body)
assert.NotEqual(t, gjson.GetBytes(body, "error.id").String(), "security_csrf_violation")
})
}
func TestExchangeCode(t *testing.T) {
t.Parallel()
_, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/identity.schema.json")),
)
ts, _, _, _ := testhelpers.NewKratosServerWithCSRFAndRouters(t, reg)
ctx := context.Background()
newRegistrationFlow := func(t *testing.T) *registration.Flow {
t.Helper()
req := &http.Request{URL: urlx.ParseOrPanic("/")}
f, err := registration.NewFlow(reg, req, flow.TypeAPI)
require.NoError(t, err)
require.NoError(t, reg.RegistrationFlowPersister().CreateRegistrationFlow(ctx, f))
return f
}
exchangeURL := func(initCode, returnToCode string) string {
return fmt.Sprintf("%s/sessions/token-exchange?init_code=%s&return_to_code=%s", ts.URL, initCode, returnToCode)
}
t.Run("case=returns 400 when codes are missing", func(t *testing.T) {
t.Parallel()
res, err := ts.Client().Get(ts.URL + "/sessions/token-exchange")
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
})
t.Run("case=returns 404 for invalid codes", func(t *testing.T) {
t.Parallel()
res, err := ts.Client().Get(exchangeURL("invalid_init", "invalid_return"))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
assert.Equal(t, http.StatusNotFound, res.StatusCode)
})
t.Run("case=returns 422 with flow when exchanger exists but has no session", func(t *testing.T) {
t.Parallel()
f := newRegistrationFlow(t)
e, err := reg.SessionTokenExchangePersister().CreateSessionTokenExchanger(ctx, f.ID)
require.NoError(t, err)
res, err := ts.Client().Get(exchangeURL(e.InitCode, e.ReturnToCode))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
assert.Equal(t, http.StatusUnprocessableEntity, res.StatusCode)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.Equal(t, f.ID.String(), gjson.GetBytes(body, "id").String())
})
t.Run("case=returns 404 when exchanger exists but flow was deleted", func(t *testing.T) {
t.Parallel()
// Create an exchanger with a flow ID that has no corresponding persisted flow.
orphanFlowID := uuid.Must(uuid.NewV4())
e, err := reg.SessionTokenExchangePersister().CreateSessionTokenExchanger(ctx, orphanFlowID)
require.NoError(t, err)
res, err := ts.Client().Get(exchangeURL(e.InitCode, e.ReturnToCode))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
assert.Equal(t, http.StatusNotFound, res.StatusCode)
})
t.Run("case=returns 200 with session when exchanger has a session", func(t *testing.T) {
t.Parallel()
f := newRegistrationFlow(t)
e, err := reg.SessionTokenExchangePersister().CreateSessionTokenExchanger(ctx, f.ID)
require.NoError(t, err)
i := identity.NewIdentity("")
require.NoError(t, reg.IdentityManager().Create(ctx, i))
req := &http.Request{URL: urlx.ParseOrPanic("/")}
sess, err := testhelpers.NewActiveSession(req, reg, i, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
require.NoError(t, err)
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
require.NoError(t, reg.SessionTokenExchangePersister().UpdateSessionOnExchanger(ctx, f.ID, sess.ID))
res, err := ts.Client().Get(exchangeURL(e.InitCode, e.ReturnToCode))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
assert.Equal(t, http.StatusOK, res.StatusCode)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.NotEmpty(t, gjson.GetBytes(body, "session_token").String())
assert.Equal(t, sess.ID.String(), gjson.GetBytes(body, "session.id").String())
})
}
type byCreatedAt []Session
func (s byCreatedAt) Len() int { return len(s) }
func (s byCreatedAt) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
func (s byCreatedAt) Less(i, j int) bool {
return s[i].CreatedAt.Before(s[j].CreatedAt)
}