Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 666b67f

Browse files
committedFeb 26, 2025··
feat: stateless logout
1 parent adf8fb2 commit 666b67f

26 files changed

+123
-526
lines changed
 

‎consent/handler.go

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -932,9 +932,7 @@ func (h *Handler) rejectOAuth2ConsentRequest(w http.ResponseWriter, r *http.Requ
932932
// Accept OAuth 2.0 Logout Request
933933
//
934934
// swagger:parameters acceptOAuth2LogoutRequest
935-
//
936-
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
937-
type acceptOAuth2LogoutRequest struct {
935+
type _ struct {
938936
// OAuth 2.0 Logout Request Challenge
939937
//
940938
// in: query
@@ -964,23 +962,21 @@ func (h *Handler) acceptOAuth2LogoutRequest(w http.ResponseWriter, r *http.Reque
964962
r.URL.Query().Get("challenge"),
965963
)
966964

967-
c, err := h.r.ConsentManager().AcceptLogoutRequest(r.Context(), challenge)
965+
verifier, err := h.r.ConsentManager().AcceptLogoutRequest(r.Context(), challenge)
968966
if err != nil {
969967
h.r.Writer().WriteError(w, r, err)
970968
return
971969
}
972970

973971
h.r.Writer().Write(w, r, &flow.OAuth2RedirectTo{
974-
RedirectTo: urlx.SetQuery(urlx.AppendPaths(h.c.PublicURL(r.Context()), "/oauth2/sessions/logout"), url.Values{"logout_verifier": {c.Verifier}}).String(),
972+
RedirectTo: urlx.SetQuery(urlx.AppendPaths(h.c.PublicURL(r.Context()), "/oauth2/sessions/logout"), url.Values{"logout_verifier": {verifier}}).String(),
975973
})
976974
}
977975

978976
// Reject OAuth 2.0 Logout Request
979977
//
980978
// swagger:parameters rejectOAuth2LogoutRequest
981-
//
982-
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
983-
type rejectOAuth2LogoutRequest struct {
979+
type _ struct {
984980
// in: query
985981
// required: true
986982
Challenge string `json:"logout_challenge"`
@@ -1020,9 +1016,7 @@ func (h *Handler) rejectOAuth2LogoutRequest(w http.ResponseWriter, r *http.Reque
10201016
// Get OAuth 2.0 Logout Request
10211017
//
10221018
// swagger:parameters getOAuth2LogoutRequest
1023-
//
1024-
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
1025-
type getOAuth2LogoutRequest struct {
1019+
type _ struct {
10261020
// in: query
10271021
// required: true
10281022
Challenge string `json:"logout_challenge"`
@@ -1060,13 +1054,6 @@ func (h *Handler) getOAuth2LogoutRequest(w http.ResponseWriter, r *http.Request,
10601054
request.Client.Secret = ""
10611055
}
10621056

1063-
if request.WasHandled {
1064-
h.r.Writer().WriteCode(w, r, http.StatusGone, &flow.OAuth2RedirectTo{
1065-
RedirectTo: request.RequestURL,
1066-
})
1067-
return
1068-
}
1069-
10701057
h.r.Writer().Write(w, r, request)
10711058
}
10721059

‎consent/handler_test.go

Lines changed: 0 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -31,61 +31,6 @@ import (
3131
"github.com/ory/x/sqlxx"
3232
)
3333

34-
func TestGetLogoutRequest(t *testing.T) {
35-
for k, tc := range []struct {
36-
exists bool
37-
handled bool
38-
status int
39-
}{
40-
{false, false, http.StatusNotFound},
41-
{true, false, http.StatusOK},
42-
{true, true, http.StatusGone},
43-
} {
44-
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
45-
ctx := context.Background()
46-
key := fmt.Sprint(k)
47-
challenge := "challenge" + key
48-
requestURL := "http://192.0.2.1"
49-
50-
conf := testhelpers.NewConfigurationWithDefaults()
51-
reg := testhelpers.NewRegistryMemory(t, conf, &contextx.Default{})
52-
53-
if tc.exists {
54-
cl := &client.Client{ID: "client" + key}
55-
require.NoError(t, reg.ClientManager().CreateClient(ctx, cl))
56-
require.NoError(t, reg.ConsentManager().CreateLogoutRequest(context.TODO(), &flow.LogoutRequest{
57-
Client: cl,
58-
ID: challenge,
59-
WasHandled: tc.handled,
60-
RequestURL: requestURL,
61-
}))
62-
}
63-
64-
h := NewHandler(reg, conf)
65-
r := x.NewRouterAdmin(conf.AdminURL)
66-
h.SetRoutes(r)
67-
ts := httptest.NewServer(r)
68-
defer ts.Close()
69-
70-
c := &http.Client{}
71-
resp, err := c.Get(ts.URL + "/admin" + LogoutPath + "?challenge=" + challenge)
72-
require.NoError(t, err)
73-
require.EqualValues(t, tc.status, resp.StatusCode)
74-
75-
if tc.handled {
76-
var result flow.OAuth2RedirectTo
77-
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
78-
require.Equal(t, requestURL, result.RedirectTo)
79-
} else if tc.exists {
80-
var result flow.LogoutRequest
81-
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
82-
require.Equal(t, challenge, result.ID)
83-
require.Equal(t, requestURL, result.RequestURL)
84-
}
85-
})
86-
}
87-
}
88-
8934
func TestGetLoginRequest(t *testing.T) {
9035
for k, tc := range []struct {
9136
exists bool

‎consent/manager.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,9 @@ type (
5757
ListUserAuthenticatedClientsWithFrontChannelLogout(ctx context.Context, subject, sid string) ([]client.Client, error)
5858
ListUserAuthenticatedClientsWithBackChannelLogout(ctx context.Context, subject, sid string) ([]client.Client, error)
5959

60-
CreateLogoutRequest(ctx context.Context, request *flow.LogoutRequest) error
60+
CreateLogoutChallenge(ctx context.Context, request *flow.LogoutRequest) (challenge string, err error)
6161
GetLogoutRequest(ctx context.Context, challenge string) (*flow.LogoutRequest, error)
62-
AcceptLogoutRequest(ctx context.Context, challenge string) (*flow.LogoutRequest, error)
62+
AcceptLogoutRequest(ctx context.Context, challenge string) (verifier string, err error)
6363
RejectLogoutRequest(ctx context.Context, challenge string) error
6464
VerifyAndInvalidateLogoutRequest(ctx context.Context, verifier string) (*flow.LogoutRequest, error)
6565

‎consent/strategy_default.go

Lines changed: 29 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,13 @@ import (
2121
"github.com/sirupsen/logrus"
2222
"go.opentelemetry.io/otel/trace"
2323

24-
"github.com/ory/hydra/v2/flow"
25-
"github.com/ory/hydra/v2/oauth2/flowctx"
26-
2724
"github.com/ory/fosite"
2825
"github.com/ory/fosite/handler/openid"
2926
"github.com/ory/fosite/token/jwt"
3027
"github.com/ory/hydra/v2/client"
3128
"github.com/ory/hydra/v2/driver/config"
29+
"github.com/ory/hydra/v2/flow"
30+
"github.com/ory/hydra/v2/oauth2/flowctx"
3231
"github.com/ory/hydra/v2/x"
3332
"github.com/ory/x/errorsx"
3433
"github.com/ory/x/mapx"
@@ -883,21 +882,18 @@ func (s *DefaultStrategy) issueLogoutVerifier(ctx context.Context, w http.Respon
883882
return nil, err
884883
}
885884

886-
challenge := uuid.New()
887-
if err := s.r.ConsentManager().CreateLogoutRequest(r.Context(), &flow.LogoutRequest{
888-
RequestURL: r.URL.String(),
889-
ID: challenge,
890-
Subject: session.Subject,
891-
SessionID: session.ID,
892-
Verifier: uuid.New(),
893-
RequestedAt: sqlxx.NullTime(time.Now().UTC().Round(time.Second)),
894-
ExpiresAt: sqlxx.NullTime(time.Now().UTC().Round(time.Second).Add(s.c.ConsentRequestMaxAge(ctx))),
895-
RPInitiated: false,
896-
897-
// PostLogoutRedirectURI is set to the value from config.Provider().LogoutRedirectURL()
885+
now := time.Now().UTC().Round(time.Second)
886+
challenge, err := s.r.ConsentManager().CreateLogoutChallenge(ctx, &flow.LogoutRequest{
887+
RequestURL: r.URL.String(),
888+
Subject: session.Subject,
889+
SessionID: session.ID,
890+
RequestedAt: now,
891+
ExpiresAt: now.Add(s.c.ConsentRequestMaxAge(ctx)),
892+
RPInitiated: false,
898893
PostLogoutRedirectURI: redir,
899-
}); err != nil {
900-
return nil, err
894+
})
895+
if err != nil {
896+
return nil, errors.WithStack(err)
901897
}
902898

903899
s.r.AuditLogger().
@@ -923,13 +919,13 @@ func (s *DefaultStrategy) issueLogoutVerifier(ctx context.Context, w http.Respon
923919
)
924920
}
925921

926-
now := time.Now().UTC().Unix()
927-
if !claims.VerifyIssuedAt(now, true) {
922+
now := time.Now().UTC().Round(time.Second)
923+
if !claims.VerifyIssuedAt(now.Unix(), true) {
928924
return nil, errorsx.WithStack(fosite.ErrInvalidRequest.
929925
WithHintf(
930926
`Logout failed because iat claim value '%.0f' from query parameter id_token_hint is before now ('%d').`,
931927
mapx.GetFloat64Default(mksi, "iat", float64(0)),
932-
now,
928+
now.Unix(),
933929
),
934930
)
935931
}
@@ -967,6 +963,7 @@ func (s *DefaultStrategy) issueLogoutVerifier(ctx context.Context, w http.Respon
967963
return nil, errorsx.WithStack(fosite.ErrInvalidRequest.
968964
WithHint("Logout failed because none of the listed audiences is a registered OAuth 2.0 Client."))
969965
}
966+
cl.Secret = "" // We don't want to expose the client secret.
970967

971968
if len(requestedRedir) > 0 {
972969
var f *url.URL
@@ -1007,20 +1004,19 @@ func (s *DefaultStrategy) issueLogoutVerifier(ctx context.Context, w http.Respon
10071004
return nil, err
10081005
}
10091006

1010-
challenge := uuid.New()
1011-
if err := s.r.ConsentManager().CreateLogoutRequest(r.Context(), &flow.LogoutRequest{
1012-
RequestURL: r.URL.String(),
1013-
ID: challenge,
1014-
SessionID: hintSid,
1015-
Subject: session.Subject,
1016-
Verifier: uuid.New(),
1017-
Client: cl,
1018-
RPInitiated: true,
1019-
1020-
// PostLogoutRedirectURI is set to the value from config.Provider().LogoutRedirectURL()
1007+
now = time.Now().UTC().Round(time.Second)
1008+
challenge, err := s.r.ConsentManager().CreateLogoutChallenge(ctx, &flow.LogoutRequest{
1009+
RequestURL: r.URL.String(),
1010+
Subject: session.Subject,
1011+
SessionID: hintSid,
1012+
RequestedAt: now,
1013+
ExpiresAt: now.Add(s.c.ConsentRequestMaxAge(ctx)),
1014+
RPInitiated: true,
10211015
PostLogoutRedirectURI: redir,
1022-
}); err != nil {
1023-
return nil, err
1016+
Client: cl,
1017+
})
1018+
if err != nil {
1019+
return nil, errors.WithStack(err)
10241020
}
10251021

10261022
http.Redirect(w, r, urlx.SetQuery(s.c.LogoutURL(ctx), url.Values{"logout_challenge": {challenge}}).String(), http.StatusFound)

‎consent/strategy_logout_test.go

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,19 @@ import (
1616
"testing"
1717
"time"
1818

19-
"github.com/ory/hydra/v2/internal/kratos"
20-
"github.com/ory/x/pointerx"
21-
2219
"github.com/stretchr/testify/assert"
2320
"github.com/stretchr/testify/require"
2421
"github.com/tidwall/gjson"
2522

2623
jwtgo "github.com/ory/fosite/token/jwt"
27-
2824
hydra "github.com/ory/hydra-client-go/v2"
2925
"github.com/ory/hydra/v2/client"
3026
"github.com/ory/hydra/v2/driver/config"
27+
"github.com/ory/hydra/v2/internal/kratos"
3128
"github.com/ory/hydra/v2/internal/testhelpers"
3229
"github.com/ory/x/contextx"
3330
"github.com/ory/x/ioutilx"
31+
"github.com/ory/x/pointerx"
3432
)
3533

3634
func TestLogoutFlows(t *testing.T) {
@@ -163,14 +161,17 @@ func TestLogoutFlows(t *testing.T) {
163161
defer wg.Done()
164162
}
165163

166-
res, _, err := adminApi.OAuth2API.GetOAuth2LogoutRequest(ctx).LogoutChallenge(r.URL.Query().Get("logout_challenge")).Execute()
164+
challenge := r.URL.Query().Get("logout_challenge")
165+
res, _, err := adminApi.OAuth2API.GetOAuth2LogoutRequest(ctx).LogoutChallenge(challenge).Execute()
167166
if cb != nil {
168167
cb(t, res, err)
169168
} else {
170169
require.NoError(t, err)
171170
}
171+
require.NotNil(t, res)
172+
require.NotNil(t, res.Challenge)
172173

173-
v, _, err := adminApi.OAuth2API.AcceptOAuth2LogoutRequest(ctx).LogoutChallenge(r.URL.Query().Get("logout_challenge")).Execute()
174+
v, _, err := adminApi.OAuth2API.AcceptOAuth2LogoutRequest(ctx).LogoutChallenge(*res.Challenge).Execute()
174175
require.NoError(t, err)
175176
require.NotEmpty(t, v.RedirectTo)
176177
http.Redirect(w, r, v.RedirectTo, http.StatusFound)
@@ -277,20 +278,20 @@ func TestLogoutFlows(t *testing.T) {
277278
acceptLoginAs(t, subject)
278279
browser := createBrowserWithSession(t, createSampleClient(t))
279280

280-
var logoutReq *hydra.OAuth2LogoutRequest
281+
var logoutChallenge string
281282
setupCheckAndAcceptLogoutHandler(t, nil, func(t *testing.T, req *hydra.OAuth2LogoutRequest, err error) {
282283
require.NoError(t, err)
283-
logoutReq = req
284+
require.NotNil(t, req.Challenge)
285+
logoutChallenge = *req.Challenge
284286
})
285287

286288
// run once to log out
287289
logoutAndExpectPostLogoutPage(t, browser, http.MethodGet, url.Values{}, defaultRedirectedMessage)
288290

289-
// run again to ensure that the logout challenge is invalid
290-
_, _, err := adminApi.OAuth2API.GetOAuth2LogoutRequest(ctx).LogoutChallenge(logoutReq.GetChallenge()).Execute()
291-
assert.Error(t, err)
291+
require.NotZero(t, logoutChallenge)
292292

293-
v, _, err := adminApi.OAuth2API.AcceptOAuth2LogoutRequest(ctx).LogoutChallenge(logoutReq.GetChallenge()).Execute()
293+
// double-submit: still works
294+
v, _, err := adminApi.OAuth2API.AcceptOAuth2LogoutRequest(ctx).LogoutChallenge(logoutChallenge).Execute()
294295
require.NoError(t, err)
295296
require.NotEmpty(t, v.RedirectTo)
296297

@@ -485,7 +486,7 @@ func TestLogoutFlows(t *testing.T) {
485486
c := createSampleClient(t)
486487
acceptLoginAs(t, subject)
487488

488-
setupCheckAndAcceptLogoutHandler(t, nil, func(t *testing.T, res *hydra.OAuth2LogoutRequest, err error) {
489+
setupCheckAndAcceptLogoutHandler(t, nil, func(t *testing.T, _ *hydra.OAuth2LogoutRequest, _ error) {
489490
t.Fatalf("Logout should not have been called")
490491
})
491492
browser := createBrowserWithSession(t, c)

‎consent/strategy_oauth_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ func TestStrategyLoginConsentNext(t *testing.T) {
5353
adminClient := hydra.NewAPIClient(hydra.NewConfiguration())
5454
adminClient.GetConfig().Servers = hydra.ServerConfigurations{{URL: adminTS.URL}}
5555

56-
oauth2Config := func(t *testing.T, c *client.Client) *oauth2.Config {
56+
oauth2Config := func(_ *testing.T, c *client.Client) *oauth2.Config {
5757
return &oauth2.Config{
5858
ClientID: c.GetID(),
5959
ClientSecret: c.Secret,

0 commit comments

Comments
 (0)
Please sign in to comment.