feat: stateless logout

This commit is contained in:
Arne Luenser 2025-02-06 17:10:48 +01:00
parent adf8fb2086
commit 666b67ff85
No known key found for this signature in database
26 changed files with 123 additions and 526 deletions

View File

@ -932,9 +932,7 @@ func (h *Handler) rejectOAuth2ConsentRequest(w http.ResponseWriter, r *http.Requ
// Accept OAuth 2.0 Logout Request
//
// swagger:parameters acceptOAuth2LogoutRequest
//
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type acceptOAuth2LogoutRequest struct {
type _ struct {
// OAuth 2.0 Logout Request Challenge
//
// in: query
@ -964,23 +962,21 @@ func (h *Handler) acceptOAuth2LogoutRequest(w http.ResponseWriter, r *http.Reque
r.URL.Query().Get("challenge"),
)
c, err := h.r.ConsentManager().AcceptLogoutRequest(r.Context(), challenge)
verifier, err := h.r.ConsentManager().AcceptLogoutRequest(r.Context(), challenge)
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
h.r.Writer().Write(w, r, &flow.OAuth2RedirectTo{
RedirectTo: urlx.SetQuery(urlx.AppendPaths(h.c.PublicURL(r.Context()), "/oauth2/sessions/logout"), url.Values{"logout_verifier": {c.Verifier}}).String(),
RedirectTo: urlx.SetQuery(urlx.AppendPaths(h.c.PublicURL(r.Context()), "/oauth2/sessions/logout"), url.Values{"logout_verifier": {verifier}}).String(),
})
}
// Reject OAuth 2.0 Logout Request
//
// swagger:parameters rejectOAuth2LogoutRequest
//
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type rejectOAuth2LogoutRequest struct {
type _ struct {
// in: query
// required: true
Challenge string `json:"logout_challenge"`
@ -1020,9 +1016,7 @@ func (h *Handler) rejectOAuth2LogoutRequest(w http.ResponseWriter, r *http.Reque
// Get OAuth 2.0 Logout Request
//
// swagger:parameters getOAuth2LogoutRequest
//
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type getOAuth2LogoutRequest struct {
type _ struct {
// in: query
// required: true
Challenge string `json:"logout_challenge"`
@ -1060,13 +1054,6 @@ func (h *Handler) getOAuth2LogoutRequest(w http.ResponseWriter, r *http.Request,
request.Client.Secret = ""
}
if request.WasHandled {
h.r.Writer().WriteCode(w, r, http.StatusGone, &flow.OAuth2RedirectTo{
RedirectTo: request.RequestURL,
})
return
}
h.r.Writer().Write(w, r, request)
}

View File

@ -31,61 +31,6 @@ import (
"github.com/ory/x/sqlxx"
)
func TestGetLogoutRequest(t *testing.T) {
for k, tc := range []struct {
exists bool
handled bool
status int
}{
{false, false, http.StatusNotFound},
{true, false, http.StatusOK},
{true, true, http.StatusGone},
} {
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
ctx := context.Background()
key := fmt.Sprint(k)
challenge := "challenge" + key
requestURL := "http://192.0.2.1"
conf := testhelpers.NewConfigurationWithDefaults()
reg := testhelpers.NewRegistryMemory(t, conf, &contextx.Default{})
if tc.exists {
cl := &client.Client{ID: "client" + key}
require.NoError(t, reg.ClientManager().CreateClient(ctx, cl))
require.NoError(t, reg.ConsentManager().CreateLogoutRequest(context.TODO(), &flow.LogoutRequest{
Client: cl,
ID: challenge,
WasHandled: tc.handled,
RequestURL: requestURL,
}))
}
h := NewHandler(reg, conf)
r := x.NewRouterAdmin(conf.AdminURL)
h.SetRoutes(r)
ts := httptest.NewServer(r)
defer ts.Close()
c := &http.Client{}
resp, err := c.Get(ts.URL + "/admin" + LogoutPath + "?challenge=" + challenge)
require.NoError(t, err)
require.EqualValues(t, tc.status, resp.StatusCode)
if tc.handled {
var result flow.OAuth2RedirectTo
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
require.Equal(t, requestURL, result.RedirectTo)
} else if tc.exists {
var result flow.LogoutRequest
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
require.Equal(t, challenge, result.ID)
require.Equal(t, requestURL, result.RequestURL)
}
})
}
}
func TestGetLoginRequest(t *testing.T) {
for k, tc := range []struct {
exists bool

View File

@ -57,9 +57,9 @@ type (
ListUserAuthenticatedClientsWithFrontChannelLogout(ctx context.Context, subject, sid string) ([]client.Client, error)
ListUserAuthenticatedClientsWithBackChannelLogout(ctx context.Context, subject, sid string) ([]client.Client, error)
CreateLogoutRequest(ctx context.Context, request *flow.LogoutRequest) error
CreateLogoutChallenge(ctx context.Context, request *flow.LogoutRequest) (challenge string, err error)
GetLogoutRequest(ctx context.Context, challenge string) (*flow.LogoutRequest, error)
AcceptLogoutRequest(ctx context.Context, challenge string) (*flow.LogoutRequest, error)
AcceptLogoutRequest(ctx context.Context, challenge string) (verifier string, err error)
RejectLogoutRequest(ctx context.Context, challenge string) error
VerifyAndInvalidateLogoutRequest(ctx context.Context, verifier string) (*flow.LogoutRequest, error)

View File

@ -21,14 +21,13 @@ import (
"github.com/sirupsen/logrus"
"go.opentelemetry.io/otel/trace"
"github.com/ory/hydra/v2/flow"
"github.com/ory/hydra/v2/oauth2/flowctx"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/openid"
"github.com/ory/fosite/token/jwt"
"github.com/ory/hydra/v2/client"
"github.com/ory/hydra/v2/driver/config"
"github.com/ory/hydra/v2/flow"
"github.com/ory/hydra/v2/oauth2/flowctx"
"github.com/ory/hydra/v2/x"
"github.com/ory/x/errorsx"
"github.com/ory/x/mapx"
@ -883,21 +882,18 @@ func (s *DefaultStrategy) issueLogoutVerifier(ctx context.Context, w http.Respon
return nil, err
}
challenge := uuid.New()
if err := s.r.ConsentManager().CreateLogoutRequest(r.Context(), &flow.LogoutRequest{
now := time.Now().UTC().Round(time.Second)
challenge, err := s.r.ConsentManager().CreateLogoutChallenge(ctx, &flow.LogoutRequest{
RequestURL: r.URL.String(),
ID: challenge,
Subject: session.Subject,
SessionID: session.ID,
Verifier: uuid.New(),
RequestedAt: sqlxx.NullTime(time.Now().UTC().Round(time.Second)),
ExpiresAt: sqlxx.NullTime(time.Now().UTC().Round(time.Second).Add(s.c.ConsentRequestMaxAge(ctx))),
RequestedAt: now,
ExpiresAt: now.Add(s.c.ConsentRequestMaxAge(ctx)),
RPInitiated: false,
// PostLogoutRedirectURI is set to the value from config.Provider().LogoutRedirectURL()
PostLogoutRedirectURI: redir,
}); err != nil {
return nil, err
})
if err != nil {
return nil, errors.WithStack(err)
}
s.r.AuditLogger().
@ -923,13 +919,13 @@ func (s *DefaultStrategy) issueLogoutVerifier(ctx context.Context, w http.Respon
)
}
now := time.Now().UTC().Unix()
if !claims.VerifyIssuedAt(now, true) {
now := time.Now().UTC().Round(time.Second)
if !claims.VerifyIssuedAt(now.Unix(), true) {
return nil, errorsx.WithStack(fosite.ErrInvalidRequest.
WithHintf(
`Logout failed because iat claim value '%.0f' from query parameter id_token_hint is before now ('%d').`,
mapx.GetFloat64Default(mksi, "iat", float64(0)),
now,
now.Unix(),
),
)
}
@ -967,6 +963,7 @@ func (s *DefaultStrategy) issueLogoutVerifier(ctx context.Context, w http.Respon
return nil, errorsx.WithStack(fosite.ErrInvalidRequest.
WithHint("Logout failed because none of the listed audiences is a registered OAuth 2.0 Client."))
}
cl.Secret = "" // We don't want to expose the client secret.
if len(requestedRedir) > 0 {
var f *url.URL
@ -1007,20 +1004,19 @@ func (s *DefaultStrategy) issueLogoutVerifier(ctx context.Context, w http.Respon
return nil, err
}
challenge := uuid.New()
if err := s.r.ConsentManager().CreateLogoutRequest(r.Context(), &flow.LogoutRequest{
now = time.Now().UTC().Round(time.Second)
challenge, err := s.r.ConsentManager().CreateLogoutChallenge(ctx, &flow.LogoutRequest{
RequestURL: r.URL.String(),
ID: challenge,
SessionID: hintSid,
Subject: session.Subject,
Verifier: uuid.New(),
Client: cl,
SessionID: hintSid,
RequestedAt: now,
ExpiresAt: now.Add(s.c.ConsentRequestMaxAge(ctx)),
RPInitiated: true,
// PostLogoutRedirectURI is set to the value from config.Provider().LogoutRedirectURL()
PostLogoutRedirectURI: redir,
}); err != nil {
return nil, err
Client: cl,
})
if err != nil {
return nil, errors.WithStack(err)
}
http.Redirect(w, r, urlx.SetQuery(s.c.LogoutURL(ctx), url.Values{"logout_challenge": {challenge}}).String(), http.StatusFound)

View File

@ -16,21 +16,19 @@ import (
"testing"
"time"
"github.com/ory/hydra/v2/internal/kratos"
"github.com/ory/x/pointerx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
jwtgo "github.com/ory/fosite/token/jwt"
hydra "github.com/ory/hydra-client-go/v2"
"github.com/ory/hydra/v2/client"
"github.com/ory/hydra/v2/driver/config"
"github.com/ory/hydra/v2/internal/kratos"
"github.com/ory/hydra/v2/internal/testhelpers"
"github.com/ory/x/contextx"
"github.com/ory/x/ioutilx"
"github.com/ory/x/pointerx"
)
func TestLogoutFlows(t *testing.T) {
@ -163,14 +161,17 @@ func TestLogoutFlows(t *testing.T) {
defer wg.Done()
}
res, _, err := adminApi.OAuth2API.GetOAuth2LogoutRequest(ctx).LogoutChallenge(r.URL.Query().Get("logout_challenge")).Execute()
challenge := r.URL.Query().Get("logout_challenge")
res, _, err := adminApi.OAuth2API.GetOAuth2LogoutRequest(ctx).LogoutChallenge(challenge).Execute()
if cb != nil {
cb(t, res, err)
} else {
require.NoError(t, err)
}
require.NotNil(t, res)
require.NotNil(t, res.Challenge)
v, _, err := adminApi.OAuth2API.AcceptOAuth2LogoutRequest(ctx).LogoutChallenge(r.URL.Query().Get("logout_challenge")).Execute()
v, _, err := adminApi.OAuth2API.AcceptOAuth2LogoutRequest(ctx).LogoutChallenge(*res.Challenge).Execute()
require.NoError(t, err)
require.NotEmpty(t, v.RedirectTo)
http.Redirect(w, r, v.RedirectTo, http.StatusFound)
@ -277,20 +278,20 @@ func TestLogoutFlows(t *testing.T) {
acceptLoginAs(t, subject)
browser := createBrowserWithSession(t, createSampleClient(t))
var logoutReq *hydra.OAuth2LogoutRequest
var logoutChallenge string
setupCheckAndAcceptLogoutHandler(t, nil, func(t *testing.T, req *hydra.OAuth2LogoutRequest, err error) {
require.NoError(t, err)
logoutReq = req
require.NotNil(t, req.Challenge)
logoutChallenge = *req.Challenge
})
// run once to log out
logoutAndExpectPostLogoutPage(t, browser, http.MethodGet, url.Values{}, defaultRedirectedMessage)
// run again to ensure that the logout challenge is invalid
_, _, err := adminApi.OAuth2API.GetOAuth2LogoutRequest(ctx).LogoutChallenge(logoutReq.GetChallenge()).Execute()
assert.Error(t, err)
require.NotZero(t, logoutChallenge)
v, _, err := adminApi.OAuth2API.AcceptOAuth2LogoutRequest(ctx).LogoutChallenge(logoutReq.GetChallenge()).Execute()
// double-submit: still works
v, _, err := adminApi.OAuth2API.AcceptOAuth2LogoutRequest(ctx).LogoutChallenge(logoutChallenge).Execute()
require.NoError(t, err)
require.NotEmpty(t, v.RedirectTo)
@ -485,7 +486,7 @@ func TestLogoutFlows(t *testing.T) {
c := createSampleClient(t)
acceptLoginAs(t, subject)
setupCheckAndAcceptLogoutHandler(t, nil, func(t *testing.T, res *hydra.OAuth2LogoutRequest, err error) {
setupCheckAndAcceptLogoutHandler(t, nil, func(t *testing.T, _ *hydra.OAuth2LogoutRequest, _ error) {
t.Fatalf("Logout should not have been called")
})
browser := createBrowserWithSession(t, c)

View File

@ -53,7 +53,7 @@ func TestStrategyLoginConsentNext(t *testing.T) {
adminClient := hydra.NewAPIClient(hydra.NewConfiguration())
adminClient.GetConfig().Servers = hydra.ServerConfigurations{{URL: adminTS.URL}}
oauth2Config := func(t *testing.T, c *client.Client) *oauth2.Config {
oauth2Config := func(_ *testing.T, c *client.Client) *oauth2.Config {
return &oauth2.Config{
ClientID: c.GetID(),
ClientSecret: c.Secret,

View File

@ -118,15 +118,13 @@ func MockLogoutRequest(key string, withClient bool, network string) (c *flow.Log
}
return &flow.LogoutRequest{
Subject: "subject" + key,
ID: makeID("challenge", network, key),
Verifier: makeID("verifier", network, key),
SessionID: makeID("session", network, key),
RPInitiated: true,
RequestURL: "http://request-me/",
PostLogoutRedirectURI: "http://redirect-me/",
WasHandled: false,
Accepted: false,
Client: cl,
RequestedAt: time.Now().UTC().Add(-time.Minute),
ExpiresAt: time.Now().UTC().Add(time.Hour),
}
}
@ -1086,66 +1084,6 @@ func ManagerTests(deps Deps, m consent.Manager, clientManager client.Manager, fo
})
}
})
t.Run("case=LogoutRequest", func(t *testing.T) {
for k, tc := range []struct {
key string
authAt bool
withClient bool
}{
{"LogoutRequest-1", true, true},
{"LogoutRequest-2", true, true},
{"LogoutRequest-3", true, true},
{"LogoutRequest-4", true, true},
{"LogoutRequest-5", true, false},
{"LogoutRequest-6", false, false},
} {
t.Run("key="+tc.key, func(t *testing.T) {
challenge := makeID("challenge", network, tc.key)
verifier := makeID("verifier", network, tc.key)
c := MockLogoutRequest(tc.key, tc.withClient, network)
if tc.withClient {
require.NoError(t, clientManager.CreateClient(ctx, c.Client)) // Ignore errors that are caused by duplication
}
_, err := m.GetLogoutRequest(ctx, challenge)
require.Error(t, err)
require.NoError(t, m.CreateLogoutRequest(ctx, c))
got2, err := m.GetLogoutRequest(ctx, challenge)
require.NoError(t, err)
assert.False(t, got2.WasHandled)
assert.False(t, got2.Accepted)
compareLogoutRequest(t, c, got2)
if k%2 == 0 {
got2, err = m.AcceptLogoutRequest(ctx, challenge)
require.NoError(t, err)
assert.True(t, got2.Accepted)
compareLogoutRequest(t, c, got2)
got3, err := m.VerifyAndInvalidateLogoutRequest(ctx, verifier)
require.NoError(t, err)
assert.True(t, got3.Accepted)
assert.True(t, got3.WasHandled)
compareLogoutRequest(t, c, got3)
_, err = m.VerifyAndInvalidateLogoutRequest(ctx, verifier)
require.NoError(t, err)
got2, err = m.GetLogoutRequest(ctx, challenge)
require.NoError(t, err)
compareLogoutRequest(t, got3, got2)
assert.True(t, got2.WasHandled)
} else {
require.NoError(t, m.RejectLogoutRequest(ctx, challenge))
_, err = m.GetLogoutRequest(ctx, challenge)
require.Error(t, err)
}
})
}
})
})
t.Run("case=foreign key regression", func(t *testing.T) {
@ -1213,9 +1151,7 @@ func compareLogoutRequest(t *testing.T, a, b *flow.LogoutRequest) {
assert.EqualValues(t, a.Client.GetID(), b.Client.GetID())
}
assert.EqualValues(t, a.ID, b.ID)
assert.EqualValues(t, a.Subject, b.Subject)
assert.EqualValues(t, a.Verifier, b.Verifier)
assert.EqualValues(t, a.RequestURL, b.RequestURL)
assert.EqualValues(t, a.PostLogoutRedirectURI, b.PostLogoutRedirectURI)
assert.EqualValues(t, a.RPInitiated, b.RPInitiated)

View File

@ -1 +1 @@
"{\"challenge\":\"\",\"subject\":\"\",\"request_url\":\"\",\"rp_initiated\":false,\"expires_at\":null,\"requested_at\":null,\"client\":null}"
"{\"challenge\":\"\",\"subject\":\"\",\"request_url\":\"\",\"rp_initiated\":false,\"expires_at\":\"0001-01-01T00:00:00Z\",\"requested_at\":\"0001-01-01T00:00:00Z\",\"client\":null}"

View File

@ -4,21 +4,18 @@
package flow
import (
"database/sql"
"database/sql/driver"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/gobuffalo/pop/v6"
"github.com/gofrs/uuid"
"github.com/ory/x/errorsx"
"github.com/ory/fosite"
"github.com/ory/hydra/v2/client"
"github.com/ory/x/sqlcon"
"github.com/ory/x/sqlxx"
)
@ -478,58 +475,27 @@ func (n *OAuth2ConsentRequestOpenIDConnectContext) Value() (driver.Value, error)
//
// swagger:model oAuth2LogoutRequest
type LogoutRequest struct {
// Challenge is the identifier of the logout authentication request.
ID string `json:"challenge" db:"challenge"`
NID uuid.UUID `json:"-" db:"nid"`
// Challenge is used to retrieve/accept/deny the logout request.
Challenge string `json:"challenge" db:"challenge"`
// Subject is the user for whom the logout was request.
Subject string `json:"subject" db:"subject"`
// Subject is the user for whom the logout was requested.
Subject string `json:"subject"`
// SessionID is the login session ID that was requested to log out.
SessionID string `json:"sid,omitempty" db:"sid"`
SessionID string `json:"sid,omitempty"`
// RequestURL is the original Logout URL requested.
RequestURL string `json:"request_url" db:"request_url"`
RequestURL string `json:"request_url"`
// RPInitiated is set to true if the request was initiated by a Relying Party (RP), also known as an OAuth 2.0 Client.
RPInitiated bool `json:"rp_initiated" db:"rp_initiated"`
RPInitiated bool `json:"rp_initiated"`
// If set to true means that the request was already handled. This
// can happen on form double-submit or other errors. If this is set
// we recommend redirecting the user to `request_url` to re-initiate
// the flow.
WasHandled bool `json:"-" db:"was_used"`
ExpiresAt time.Time `json:"expires_at"`
RequestedAt time.Time `json:"requested_at"`
Client *client.Client `json:"client"`
Verifier string `json:"-" db:"verifier"`
PostLogoutRedirectURI string `json:"-" db:"redir_url"`
Accepted bool `json:"-" db:"accepted"`
Rejected bool `db:"rejected" json:"-"`
ClientID sql.NullString `json:"-" db:"client_id"`
ExpiresAt sqlxx.NullTime `json:"expires_at" db:"expires_at"`
RequestedAt sqlxx.NullTime `json:"requested_at" db:"requested_at"`
Client *client.Client `json:"client" db:"-"`
}
func (LogoutRequest) TableName() string {
return "hydra_oauth2_logout_request"
}
func (r *LogoutRequest) BeforeSave(_ *pop.Connection) error {
if r.Client != nil {
r.ClientID = sql.NullString{
Valid: true,
String: r.Client.GetID(),
}
}
return nil
}
func (r *LogoutRequest) AfterFind(c *pop.Connection) error {
if r.ClientID.Valid {
r.Client = &client.Client{}
return sqlcon.HandleError(c.Where("id = ?", r.ClientID.String).First(r.Client))
}
return nil
// swagger:ignore
PostLogoutRedirectURI string `json:"redir_url,omitempty"`
}
// Returned when the log out request was used.

View File

@ -3867,20 +3867,18 @@ components:
sid: sid
properties:
challenge:
description: Challenge is the identifier of the logout authentication request.
description: Challenge is used to retrieve/accept/deny the logout request.
type: string
client:
$ref: '#/components/schemas/oAuth2Client'
expires_at:
format: date-time
title: NullTime implements sql.NullTime functionality.
type: string
request_url:
description: RequestURL is the original Logout URL requested.
type: string
requested_at:
format: date-time
title: NullTime implements sql.NullTime functionality.
type: string
rp_initiated:
description: "RPInitiated is set to true if the request was initiated by\
@ -3891,7 +3889,7 @@ components:
out.
type: string
subject:
description: Subject is the user for whom the logout was request.
description: Subject is the user for whom the logout was requested.
type: string
title: Contains information about an ongoing logout request.
type: object

View File

@ -4,14 +4,14 @@
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**Challenge** | Pointer to **string** | Challenge is the identifier of the logout authentication request. | [optional]
**Challenge** | Pointer to **string** | Challenge is used to retrieve/accept/deny the logout request. | [optional]
**Client** | Pointer to [**OAuth2Client**](OAuth2Client.md) | | [optional]
**ExpiresAt** | Pointer to **time.Time** | | [optional]
**RequestUrl** | Pointer to **string** | RequestURL is the original Logout URL requested. | [optional]
**RequestedAt** | Pointer to **time.Time** | | [optional]
**RpInitiated** | Pointer to **bool** | RPInitiated is set to true if the request was initiated by a Relying Party (RP), also known as an OAuth 2.0 Client. | [optional]
**Sid** | Pointer to **string** | SessionID is the login session ID that was requested to log out. | [optional]
**Subject** | Pointer to **string** | Subject is the user for whom the logout was request. | [optional]
**Subject** | Pointer to **string** | Subject is the user for whom the logout was requested. | [optional]
## Methods

View File

@ -21,7 +21,7 @@ var _ MappedNullable = &OAuth2LogoutRequest{}
// OAuth2LogoutRequest struct for OAuth2LogoutRequest
type OAuth2LogoutRequest struct {
// Challenge is the identifier of the logout authentication request.
// Challenge is used to retrieve/accept/deny the logout request.
Challenge *string `json:"challenge,omitempty"`
Client *OAuth2Client `json:"client,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
@ -32,7 +32,7 @@ type OAuth2LogoutRequest struct {
RpInitiated *bool `json:"rp_initiated,omitempty"`
// SessionID is the login session ID that was requested to log out.
Sid *string `json:"sid,omitempty"`
// Subject is the user for whom the logout was request.
// Subject is the user for whom the logout was requested.
Subject *string `json:"subject,omitempty"`
}

View File

@ -29,6 +29,8 @@ const (
deviceVerifier
consentChallenge
consentVerifier
logoutChallenge
logoutVerifier
)
func withPurpose(purpose purpose) CodecOption { return func(ad *data) { ad.Purpose = purpose } }
@ -40,6 +42,8 @@ var (
AsDeviceVerifier = withPurpose(deviceVerifier)
AsConsentChallenge = withPurpose(consentChallenge)
AsConsentVerifier = withPurpose(consentVerifier)
AsLogoutChallenge = withPurpose(logoutChallenge)
AsLogoutVerifier = withPurpose(logoutVerifier)
)
func additionalDataFromOpts(opts ...CodecOption) []byte {

View File

@ -1,10 +0,0 @@
{
"challenge": "challenge-0009",
"subject": "subject-0009",
"sid": "session_id-0009",
"request_url": "http://request/0009",
"rp_initiated": true,
"expires_at": null,
"requested_at": null,
"client": null
}

View File

@ -1,10 +0,0 @@
{
"challenge": "challenge-0010",
"subject": "subject-0010",
"sid": "session_id-0010",
"request_url": "http://request/0010",
"rp_initiated": true,
"expires_at": null,
"requested_at": null,
"client": null
}

View File

@ -1,10 +0,0 @@
{
"challenge": "challenge-0011",
"subject": "subject-0011",
"sid": "session_id-0011",
"request_url": "http://request/0011",
"rp_initiated": true,
"expires_at": null,
"requested_at": null,
"client": null
}

View File

@ -1,10 +0,0 @@
{
"challenge": "challenge-0012",
"subject": "subject-0012",
"sid": "session_id-0012",
"request_url": "http://request/0012",
"rp_initiated": true,
"expires_at": null,
"requested_at": null,
"client": null
}

View File

@ -1,10 +0,0 @@
{
"challenge": "challenge-0013",
"subject": "subject-0013",
"sid": "session_id-0013",
"request_url": "http://request/0013",
"rp_initiated": true,
"expires_at": null,
"requested_at": null,
"client": null
}

View File

@ -1,10 +0,0 @@
{
"challenge": "challenge-0014",
"subject": "subject-0014",
"sid": "session_id-0014",
"request_url": "http://request/0014",
"rp_initiated": true,
"expires_at": null,
"requested_at": null,
"client": null
}

View File

@ -1,10 +0,0 @@
{
"challenge": "challenge-20240916105610000001",
"subject": "subject-0014",
"sid": "session_id-0014",
"request_url": "http://request/0014",
"rp_initiated": true,
"expires_at": "2022-02-15T22:20:20Z",
"requested_at": "2022-02-15T22:20:20Z",
"client": null
}

View File

@ -85,7 +85,7 @@ func TestMigrations(t *testing.T) {
})
}
var test = func(db string, c *pop.Connection) func(t *testing.T) {
var test = func(_ string, c *pop.Connection) func(t *testing.T) {
return func(t *testing.T) {
ctx := context.Background()
x.CleanSQLPop(t, c)
@ -168,19 +168,6 @@ func TestMigrations(t *testing.T) {
}
})
t.Run("case=hydra_oauth2_logout_request", func(t *testing.T) {
lrs := []flow.LogoutRequest{}
require.NoError(t, c.All(&lrs))
require.Equal(t, 7, len(lrs))
for _, s := range lrs {
testhelpersuuid.AssertUUID(t, s.NID)
s.NID = uuid.Nil
s.Client = nil
CompareWithFixture(t, s, "hydra_oauth2_logout_request", s.ID)
}
})
t.Run("case=hydra_oauth2_jti_blacklist", func(t *testing.T) {
bjtis := []oauth2.BlacklistedJTI{}
require.NoError(t, c.All(&bjtis))

View File

@ -756,79 +756,73 @@ WHERE
return cs, nil
}
func (p *Persister) CreateLogoutRequest(ctx context.Context, request *flow.LogoutRequest) (err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.CreateLogoutRequest")
func (p *Persister) CreateLogoutChallenge(ctx context.Context, request *flow.LogoutRequest) (challenge string, err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.CreateLogoutChallenge")
defer otelx.End(span, &err)
return errorsx.WithStack(p.CreateWithNetwork(ctx, request))
request.Challenge = ""
challenge, err = flowctx.Encode(ctx, p.r.FlowCipher(), request, flowctx.AsLogoutChallenge)
if err != nil {
return "", errorsx.WithStack(fosite.ErrServerError.WithWrap(err).WithHintf("Failed to encrypt the logout challenge."))
}
return challenge, nil
}
func (p *Persister) AcceptLogoutRequest(ctx context.Context, challenge string) (_ *flow.LogoutRequest, err error) {
func (p *Persister) AcceptLogoutRequest(ctx context.Context, challenge string) (verifier string, err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.AcceptLogoutRequest")
defer otelx.End(span, &err)
if err := p.Connection(ctx).RawQuery("UPDATE hydra_oauth2_logout_request SET accepted=true, rejected=false WHERE challenge=? AND nid = ?", challenge, p.NetworkID(ctx)).Exec(); err != nil {
return nil, sqlcon.HandleError(err)
req, err := flowctx.Decode[flow.LogoutRequest](ctx, p.r.FlowCipher(), challenge, flowctx.AsLogoutChallenge)
if err != nil {
return "", errorsx.WithStack(x.ErrNotFound.WithWrap(err).WithHintf("Failed to decrypt the logout challenge."))
}
req.Challenge = ""
verifier, err = flowctx.Encode(ctx, p.r.FlowCipher(), req, flowctx.AsLogoutVerifier)
if err != nil {
return "", errorsx.WithStack(fosite.ErrServerError.WithWrap(err).WithHintf("Failed to encrypty the logout verifier."))
}
return p.GetLogoutRequest(ctx, challenge)
return verifier, nil
}
func (p *Persister) RejectLogoutRequest(ctx context.Context, challenge string) (err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.RejectLogoutRequest")
defer otelx.End(span, &err)
count, err := p.Connection(ctx).
RawQuery("UPDATE hydra_oauth2_logout_request SET rejected=true, accepted=false WHERE challenge=? AND nid = ?", challenge, p.NetworkID(ctx)).
ExecWithCount()
if count == 0 {
return errorsx.WithStack(x.ErrNotFound)
} else {
return errorsx.WithStack(err)
_, err = flowctx.Decode[flow.LogoutRequest](ctx, p.r.FlowCipher(), challenge, flowctx.AsLogoutChallenge)
if err != nil {
return errorsx.WithStack(x.ErrNotFound.WithWrap(err).WithHintf("Failed to decrypt the logout challenge."))
}
return nil
}
func (p *Persister) GetLogoutRequest(ctx context.Context, challenge string) (_ *flow.LogoutRequest, err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.GetLogoutRequest")
defer otelx.End(span, &err)
var lr flow.LogoutRequest
return &lr, sqlcon.HandleError(p.QueryWithNetwork(ctx).Where("challenge = ? AND rejected = FALSE", challenge).First(&lr))
request, err := flowctx.Decode[flow.LogoutRequest](ctx, p.r.FlowCipher(), challenge, flowctx.AsLogoutChallenge)
if err != nil {
return nil, errorsx.WithStack(x.ErrNotFound.WithWrap(err).WithHintf("Failed to decrypt the logout challenge."))
}
request.Challenge = challenge
return request, nil
}
func (p *Persister) VerifyAndInvalidateLogoutRequest(ctx context.Context, verifier string) (_ *flow.LogoutRequest, err error) {
ctx, span := p.r.Tracer(ctx).Tracer().Start(ctx, "persistence.sql.VerifyAndInvalidateLogoutRequest")
defer otelx.End(span, &err)
var lr flow.LogoutRequest
if count, err := p.Connection(ctx).RawQuery(`
UPDATE hydra_oauth2_logout_request
SET was_used = TRUE
WHERE nid = ?
AND verifier = ?
AND accepted = TRUE
AND rejected = FALSE`,
p.NetworkID(ctx),
verifier,
).ExecWithCount(); count == 0 && err == nil {
return nil, errorsx.WithStack(x.ErrNotFound)
} else if err != nil {
return nil, sqlcon.HandleError(err)
}
err = sqlcon.HandleError(p.QueryWithNetwork(ctx).Where("verifier = ?", verifier).First(&lr))
lr, err := flowctx.Decode[flow.LogoutRequest](ctx, p.r.FlowCipher(), verifier, flowctx.AsLogoutVerifier)
if err != nil {
return nil, err
return nil, errorsx.WithStack(x.ErrNotFound.WithWrap(err).WithHintf("Failed to decrypt the logout verifier."))
}
if expiry := time.Time(lr.ExpiresAt);
// If the expiry is unset, we are in a legacy use case (allow logout).
// TODO: Remove this in the future.
!expiry.IsZero() && expiry.Before(time.Now().UTC()) {
if lr.ExpiresAt.Before(time.Now()) {
return nil, errorsx.WithStack(flow.ErrorLogoutFlowExpired)
}
return &lr, nil
return lr, nil
}
func (p *Persister) FlushInactiveLoginConsentRequests(ctx context.Context, notAfter time.Time, limit int, batchSize int) (err error) {

View File

@ -83,29 +83,6 @@ func (s *PersisterTestSuite) TearDownTest() {
}
}
func (s *PersisterTestSuite) TestAcceptLogoutRequest() {
t := s.T()
lr := newLogoutRequest()
for k, r := range s.registries {
t.Run("dialect="+k, func(*testing.T) {
require.NoError(t, r.ConsentManager().CreateLogoutRequest(s.t1, lr))
expected, err := r.ConsentManager().GetLogoutRequest(s.t1, lr.ID)
require.NoError(t, err)
require.Equal(t, false, expected.Accepted)
lrAccepted, err := r.ConsentManager().AcceptLogoutRequest(s.t2, lr.ID)
require.Error(t, err)
require.Equal(t, &flow.LogoutRequest{}, lrAccepted)
actual, err := r.ConsentManager().GetLogoutRequest(s.t1, lr.ID)
require.NoError(t, err)
require.Equal(t, expected, actual)
})
}
}
func (s *PersisterTestSuite) TestAddKeyGetKeyDeleteKey() {
t := s.T()
key := newKey("test-ks", "test")
@ -456,27 +433,6 @@ func (s *PersisterTestSuite) TestCreateLoginSession() {
})
}
}
func (s *PersisterTestSuite) TestCreateLogoutRequest() {
t := s.T()
for k, r := range s.registries {
t.Run(k, func(t *testing.T) {
client := &client.Client{ID: "client-id"}
lr := flow.LogoutRequest{
// TODO there is not FK for SessionID so we don't need it here; TODO make sure the missing FK is intentional
ID: uuid.Must(uuid.NewV4()).String(),
ClientID: sql.NullString{Valid: true, String: client.ID},
}
require.NoError(t, r.Persister().CreateClient(s.t1, client))
require.NoError(t, r.Persister().CreateLogoutRequest(s.t1, &lr))
actual, err := r.Persister().GetLogoutRequest(s.t1, lr.ID)
require.NoError(t, err)
require.Equal(t, s.t1NID, actual.NID)
})
}
}
func (s *PersisterTestSuite) TestCreateOpenIDConnectSession() {
t := s.T()
for k, r := range s.registries {
@ -1236,30 +1192,6 @@ func (s *PersisterTestSuite) TestGetLoginRequest() {
}
}
func (s *PersisterTestSuite) TestGetLogoutRequest() {
t := s.T()
for k, r := range s.registries {
t.Run(k, func(t *testing.T) {
client := &client.Client{ID: "client-id"}
lr := flow.LogoutRequest{
ID: uuid.Must(uuid.NewV4()).String(),
ClientID: sql.NullString{Valid: true, String: client.ID},
}
require.NoError(t, r.Persister().CreateClient(s.t1, client))
require.NoError(t, r.Persister().CreateLogoutRequest(s.t1, &lr))
actual, err := r.Persister().GetLogoutRequest(s.t2, lr.ID)
require.Error(t, err)
require.Equal(t, &flow.LogoutRequest{}, actual)
actual, err = r.Persister().GetLogoutRequest(s.t1, lr.ID)
require.NoError(t, err)
require.NotEqual(t, &flow.LogoutRequest{}, actual)
})
}
}
func (s *PersisterTestSuite) TestGetOpenIDConnectSession() {
t := s.T()
for k, r := range s.registries {
@ -1723,27 +1655,6 @@ func (s *PersisterTestSuite) TestQueryWithNetwork() {
})
}
}
func (s *PersisterTestSuite) TestRejectLogoutRequest() {
t := s.T()
for k, r := range s.registries {
t.Run(k, func(t *testing.T) {
lr := newLogoutRequest()
require.NoError(t, r.ConsentManager().CreateLogoutRequest(s.t1, lr))
require.Error(t, r.ConsentManager().RejectLogoutRequest(s.t2, lr.ID))
actual, err := r.ConsentManager().GetLogoutRequest(s.t1, lr.ID)
require.NoError(t, err)
require.Equal(t, lr, actual)
require.NoError(t, r.ConsentManager().RejectLogoutRequest(s.t1, lr.ID))
actual, err = r.ConsentManager().GetLogoutRequest(s.t1, lr.ID)
require.Error(t, err)
require.Equal(t, &flow.LogoutRequest{}, actual)
})
}
}
func (s *PersisterTestSuite) TestRevokeAccessToken() {
t := s.T()
for k, r := range s.registries {
@ -2136,62 +2047,6 @@ func (s *PersisterTestSuite) TestVerifyAndInvalidateLoginRequest() {
}
}
func (s *PersisterTestSuite) TestVerifyAndInvalidateLogoutRequest() {
t := s.T()
for k, r := range s.registries {
t.Run(k, func(t *testing.T) {
run := func(t *testing.T, lr *flow.LogoutRequest) {
lr.Verifier = uuid.Must(uuid.NewV4()).String()
lr.Accepted = true
lr.Rejected = false
require.NoError(t, r.ConsentManager().CreateLogoutRequest(s.t1, lr))
expected, err := r.ConsentManager().GetLogoutRequest(s.t1, lr.ID)
require.NoError(t, err)
lrInvalidated, err := r.ConsentManager().VerifyAndInvalidateLogoutRequest(s.t2, lr.Verifier)
require.Error(t, err)
require.Nil(t, lrInvalidated)
actual := &flow.LogoutRequest{}
require.NoError(t, r.Persister().Connection(context.Background()).Find(actual, lr.ID))
require.Equal(t, expected, actual)
lrInvalidated, err = r.ConsentManager().VerifyAndInvalidateLogoutRequest(s.t1, lr.Verifier)
require.NoError(t, err)
require.NoError(t, r.Persister().Connection(context.Background()).Find(actual, lr.ID))
require.Equal(t, lrInvalidated, actual)
require.Equal(t, true, actual.WasHandled)
}
t.Run("case=legacy logout request without expiry", func(t *testing.T) {
lr := newLogoutRequest()
run(t, lr)
})
t.Run("case=logout request with expiry", func(t *testing.T) {
lr := newLogoutRequest()
lr.ExpiresAt = sqlxx.NullTime(time.Now().Add(time.Hour))
run(t, lr)
})
t.Run("case=logout request that expired returns error", func(t *testing.T) {
lr := newLogoutRequest()
lr.ExpiresAt = sqlxx.NullTime(time.Now().UTC().Add(-time.Hour))
lr.Verifier = uuid.Must(uuid.NewV4()).String()
lr.Accepted = true
lr.Rejected = false
require.NoError(t, r.ConsentManager().CreateLogoutRequest(s.t1, lr))
_, err := r.ConsentManager().VerifyAndInvalidateLogoutRequest(s.t2, lr.Verifier)
require.ErrorIs(t, err, x.ErrNotFound)
_, err = r.ConsentManager().VerifyAndInvalidateLogoutRequest(s.t1, lr.Verifier)
require.ErrorIs(t, err, flow.ErrorLogoutFlowExpired)
})
})
}
}
func (s *PersisterTestSuite) TestWithFallbackNetworkID() {
t := s.T()
for k, r := range s.registries {
@ -2254,12 +2109,6 @@ func newGrant(keySet string, keyID string) trust.Grant {
}
}
func newLogoutRequest() *flow.LogoutRequest {
return &flow.LogoutRequest{
ID: uuid.Must(uuid.NewV4()).String(),
}
}
func newKey(ksID string, use string) jose.JSONWebKey {
ks, err := jwk.GenerateJWK(context.Background(), jose.RS256, ksID, use)
if err != nil {

View File

@ -38,7 +38,7 @@ func init() {
func testRegistry(t *testing.T, ctx context.Context, k string, t1 driver.Registry, t2 driver.Registry) {
t.Run("package=client/manager="+k, func(t *testing.T) {
t.Run("case=create-get-update-delete", client.TestHelperCreateGetUpdateDeleteClient(k, t1.Persister().Connection(context.Background()), t1.ClientManager(), t2.ClientManager()))
t.Run("case=create-get-update-delete", client.TestHelperCreateGetUpdateDeleteClient(k, t1.Persister().Connection(ctx), t1.ClientManager(), t2.ClientManager()))
t.Run("case=autogenerate-key", client.TestHelperClientAutoGenerateKey(k, t1.ClientManager()))

View File

@ -1138,21 +1138,23 @@
"oAuth2LogoutRequest": {
"properties": {
"challenge": {
"description": "Challenge is the identifier of the logout authentication request.",
"description": "Challenge is used to retrieve/accept/deny the logout request.",
"type": "string"
},
"client": {
"$ref": "#/components/schemas/oAuth2Client"
},
"expires_at": {
"$ref": "#/components/schemas/nullTime"
"format": "date-time",
"type": "string"
},
"request_url": {
"description": "RequestURL is the original Logout URL requested.",
"type": "string"
},
"requested_at": {
"$ref": "#/components/schemas/nullTime"
"format": "date-time",
"type": "string"
},
"rp_initiated": {
"description": "RPInitiated is set to true if the request was initiated by a Relying Party (RP), also known as an OAuth 2.0 Client.",
@ -1163,7 +1165,7 @@
"type": "string"
},
"subject": {
"description": "Subject is the user for whom the logout was request.",
"description": "Subject is the user for whom the logout was requested.",
"type": "string"
}
},

View File

@ -3246,21 +3246,23 @@
"title": "Contains information about an ongoing logout request.",
"properties": {
"challenge": {
"description": "Challenge is the identifier of the logout authentication request.",
"description": "Challenge is used to retrieve/accept/deny the logout request.",
"type": "string"
},
"client": {
"$ref": "#/definitions/oAuth2Client"
},
"expires_at": {
"$ref": "#/definitions/nullTime"
"type": "string",
"format": "date-time"
},
"request_url": {
"description": "RequestURL is the original Logout URL requested.",
"type": "string"
},
"requested_at": {
"$ref": "#/definitions/nullTime"
"type": "string",
"format": "date-time"
},
"rp_initiated": {
"description": "RPInitiated is set to true if the request was initiated by a Relying Party (RP), also known as an OAuth 2.0 Client.",
@ -3271,7 +3273,7 @@
"type": "string"
},
"subject": {
"description": "Subject is the user for whom the logout was request.",
"description": "Subject is the user for whom the logout was requested.",
"type": "string"
}
}