Skip to content

Commit 4db14ec

Browse files
authored
Add OIDC backchannel logout (#4513)
2 parents e4a8126 + 0b1fa89 commit 4db14ec

File tree

13 files changed

+160
-20
lines changed

13 files changed

+160
-20
lines changed

docs/delegated-auth.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,24 @@ Set-Cookie: ...
177177
Location: https://name00001-home.mycozy.cloud/
178178
```
179179

180+
#### POST /oidc/:context/logout
181+
182+
This route implements the OpenID Connect Back-Channel Logout. It means that the
183+
SSO can call this endpoint to logout the user.
184+
185+
```http
186+
POST /oidc/a-context/logout HTTP/1.1
187+
Host: name00001.mycozy.cloud
188+
Content-Type: application/x-www-form-urlencoded
189+
190+
logout_token=eyJhbGci ... .eyJpc3Mi ... .T3BlbklE ...
191+
```
192+
193+
```http
194+
HTTP/1.1 200 OK
195+
Cache-Control: no-store
196+
```
197+
180198
#### POST /oidc/access_token
181199

182200
This additional route can be used by an OAuth client (like a mobile app) when

model/session/session.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ type Session struct {
4141
LastSeen time.Time `json:"last_seen"`
4242
LongRun bool `json:"long_run"`
4343
ShortRun bool `json:"short_run"`
44+
SID string `json:"sid,omitempty"` // only present with OIDC
4445
}
4546

4647
// DocType implements couchdb.Doc
@@ -101,14 +102,15 @@ func (s *Session) OlderThan(t time.Duration) bool {
101102
}
102103

103104
// New creates a session in couchdb for the given instance
104-
func New(i *instance.Instance, duration Duration) (*Session, error) {
105+
func New(i *instance.Instance, duration Duration, sid string) (*Session, error) {
105106
now := time.Now()
106107
s := &Session{
107108
instance: i,
108109
LastSeen: now,
109110
CreatedAt: now,
110111
ShortRun: duration == ShortRun,
111112
LongRun: duration == LongRun,
113+
SID: sid,
112114
}
113115
if err := couchdb.CreateDoc(i, s); err != nil {
114116
return nil, err
@@ -302,6 +304,21 @@ func DeleteOthers(i *instance.Instance, selfSessionID string) error {
302304
return nil
303305
}
304306

307+
// DeleteBySID is used for the OIDC back-channel logout. It deletes the sessions
308+
// for the current device of the user.
309+
func DeleteBySID(inst *instance.Instance, sid string) error {
310+
return couchdb.ForeachDocs(inst, consts.Sessions, func(_ string, data json.RawMessage) error {
311+
var s Session
312+
if err := json.Unmarshal(data, &s); err != nil {
313+
return err
314+
}
315+
if s.SID == sid {
316+
s.Delete(inst)
317+
}
318+
return nil
319+
})
320+
}
321+
305322
// cookieSessionMACConfig returns the options to authenticate the session
306323
// cookie.
307324
//

pkg/couchdb/index.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@ func IndexesByDoctype(doctype string) []*mango.Index {
317317
// properly.
318318
var globalIndexes = []*mango.Index{
319319
mango.MakeIndex(consts.Exports, "by-domain", mango.IndexDef{Fields: []string{"domain", "created_at"}}),
320+
mango.MakeIndex(consts.Instances, "by-oidcid", mango.IndexDef{Fields: []string{"oidc_id"}}),
320321
}
321322

322323
// secretIndexes is the index list required on the secret databases to run

web/accounts/oauth.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ func checkLogin(next echo.HandlerFunc) echo.HandlerFunc {
344344
}
345345

346346
if !wasLoggedIn {
347-
sessionID, err := auth.SetCookieForNewSession(c, session.ShortRun)
347+
sessionID, err := auth.SetCookieForNewSession(c, session.ShortRun, "")
348348
req := c.Request()
349349
if err == nil {
350350
if err = session.StoreNewLoginEntry(inst, sessionID, "", req, "session_code", false); err != nil {

web/accounts/oauth_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ func TestOauth(t *testing.T) {
3737
setup := testutils.NewSetup(t, t.Name())
3838
ts := setup.GetTestServer("/accounts", Routes, func(r *echo.Echo) *echo.Echo {
3939
r.POST("/login", func(c echo.Context) error {
40-
sess, _ := session.New(testInstance, session.LongRun)
40+
sess, _ := session.New(testInstance, session.LongRun, "")
4141
cookie, _ := sess.ToCookie()
4242
t.Logf("cookie: %q", cookie)
4343
c.SetCookie(cookie)

web/apps/apps.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -797,7 +797,7 @@ func openWebapp(c echo.Context) error {
797797
cookie.HttpOnly = true
798798
cookie.SameSite = http.SameSiteLaxMode
799799
} else {
800-
sess, err = session.New(inst, session.NormalRun)
800+
sess, err = session.New(inst, session.NormalRun, "")
801801
if err != nil {
802802
return wrapAppsError(err)
803803
}

web/apps/apps_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ func TestApps(t *testing.T) {
9292

9393
ts := setup.GetTestServer("/apps", webApps.WebappsRoutes, func(r *echo.Echo) *echo.Echo {
9494
r.POST("/login", func(c echo.Context) error {
95-
sess, _ := session.New(testInstance, session.LongRun)
95+
sess, _ := session.New(testInstance, session.LongRun, "")
9696
cookie, _ := sess.ToCookie()
9797
c.SetCookie(cookie)
9898
return c.HTML(http.StatusOK, "OK")

web/apps/serve.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ func ServeAppFile(c echo.Context, i *instance.Instance, fs appfs.FileServer, web
162162
// reused, even if the user is already logged in and we don't want to
163163
// create a new session
164164
if checked := i.CheckAndClearSessionCode(code); checked && !isLoggedIn {
165-
sessionID, err := auth.SetCookieForNewSession(c, session.NormalRun)
165+
sessionID, err := auth.SetCookieForNewSession(c, session.NormalRun, "")
166166
req := c.Request()
167167
if err == nil {
168168
if err = session.StoreNewLoginEntry(i, sessionID, "", req, "session_code", false); err != nil {

web/auth/auth.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,9 @@ func Home(c echo.Context) error {
100100
}
101101

102102
// SetCookieForNewSession creates a new session and sets the cookie on echo context
103-
func SetCookieForNewSession(c echo.Context, duration session.Duration) (string, error) {
103+
func SetCookieForNewSession(c echo.Context, duration session.Duration, sid string) (string, error) {
104104
instance := middlewares.GetInstance(c)
105-
session, err := session.New(instance, duration)
105+
session, err := session.New(instance, duration, sid)
106106
if err != nil {
107107
return "", err
108108
}
@@ -255,7 +255,7 @@ func loginForm(c echo.Context) error {
255255
if err != nil {
256256
instance.Logger().Warnf("Delegated token check failed: %s", err)
257257
} else {
258-
sessionID, err := SetCookieForNewSession(c, session.NormalRun)
258+
sessionID, err := SetCookieForNewSession(c, session.NormalRun, "")
259259
if err != nil {
260260
return err
261261
}
@@ -284,7 +284,7 @@ func newSession(c echo.Context, inst *instance.Instance, redirect *url.URL, dura
284284
duration = session.ShortRun
285285
}
286286

287-
sessionID, err := SetCookieForNewSession(c, duration)
287+
sessionID, err := SetCookieForNewSession(c, duration, "")
288288
if err != nil {
289289
return err
290290
}

web/auth/oauth.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ func (a *AuthorizeHTTPHandler) authorizeForm(c echo.Context) error {
182182
// reused, even if the user is already logged in and we don't want to
183183
// create a new session
184184
if checked := inst.CheckAndClearSessionCode(code); checked && !isLoggedIn {
185-
sessionID, err := SetCookieForNewSession(c, session.ShortRun)
185+
sessionID, err := SetCookieForNewSession(c, session.ShortRun, "")
186186
req := c.Request()
187187
if err == nil {
188188
if err = session.StoreNewLoginEntry(inst, sessionID, "", req, "session_code", false); err != nil {

web/oidc/oidc.go

Lines changed: 109 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@ import (
2323
"github.com/cozy/cozy-stack/pkg/config/config"
2424
"github.com/cozy/cozy-stack/pkg/consts"
2525
"github.com/cozy/cozy-stack/pkg/couchdb"
26+
"github.com/cozy/cozy-stack/pkg/couchdb/mango"
2627
"github.com/cozy/cozy-stack/pkg/limits"
2728
"github.com/cozy/cozy-stack/pkg/logger"
29+
"github.com/cozy/cozy-stack/pkg/prefixer"
2830
"github.com/cozy/cozy-stack/web/auth"
2931
"github.com/cozy/cozy-stack/web/middlewares"
3032
"github.com/cozy/cozy-stack/web/statik"
@@ -147,12 +149,13 @@ func Login(c echo.Context) error {
147149
return renderError(c, nil, http.StatusNotFound, "Sorry, the session has expired.")
148150
}
149151

152+
var token string
150153
if idToken != "" && conf.IDTokenKeyURL != "" {
151154
if err := checkIDToken(conf, inst, idToken); err != nil {
152155
return renderError(c, inst, http.StatusBadRequest, err.Error())
153156
}
157+
token = idToken
154158
} else {
155-
var token string
156159
if conf.AllowOAuthToken {
157160
token = c.QueryParam("access_token")
158161
}
@@ -186,7 +189,10 @@ func Login(c echo.Context) error {
186189
}
187190
}
188191

189-
return createSessionAndRedirect(c, inst, redirect, confirm)
192+
claims := jwt.MapClaims{}
193+
_, _, _ = jwt.NewParser().ParseUnverified(token, claims)
194+
sid, _ := claims["sid"].(string)
195+
return createSessionAndRedirect(c, inst, redirect, confirm, sid)
190196
}
191197

192198
func TwoFactor(c echo.Context) error {
@@ -205,7 +211,7 @@ func TwoFactor(c echo.Context) error {
205211
}
206212

207213
if inst.ValidateTwoFactorTrustedDeviceSecret(c.Request(), trustedDeviceToken) {
208-
return createSessionAndRedirect(c, inst, redirect, confirm)
214+
return createSessionAndRedirect(c, inst, redirect, confirm, "")
209215
}
210216

211217
twoFactorToken, err := lifecycle.SendTwoFactorPasscode(inst)
@@ -224,14 +230,14 @@ func TwoFactor(c echo.Context) error {
224230
return c.Redirect(http.StatusSeeOther, inst.PageURL("/auth/twofactor", v))
225231
}
226232

227-
func createSessionAndRedirect(c echo.Context, inst *instance.Instance, redirect, confirm string) error {
233+
func createSessionAndRedirect(c echo.Context, inst *instance.Instance, redirect, confirm, sid string) error {
228234
// The OIDC danse has been made to confirm the identity of the user, not
229235
// for creating a new session.
230236
if confirm != "" {
231237
return auth.ConfirmSuccess(c, inst, confirm)
232238
}
233239

234-
sessionID, err := auth.SetCookieForNewSession(c, session.NormalRun)
240+
sessionID, err := auth.SetCookieForNewSession(c, session.NormalRun, sid)
235241
if err != nil {
236242
return err
237243
}
@@ -244,6 +250,103 @@ func createSessionAndRedirect(c echo.Context, inst *instance.Instance, redirect,
244250
return c.Redirect(http.StatusSeeOther, redirect)
245251
}
246252

253+
// Logout is the handler for the OpenID back-channel logout endpoint.
254+
func Logout(c echo.Context) error {
255+
contextName := c.Param("context")
256+
conf, err := getGenericConfig(contextName)
257+
if err != nil {
258+
return c.JSON(http.StatusBadRequest, echo.Map{
259+
"error": "No OpenID Connect is configured",
260+
})
261+
}
262+
263+
keys, err := GetIDTokenKeys(conf.IDTokenKeyURL)
264+
if err != nil {
265+
return c.JSON(http.StatusBadRequest, echo.Map{
266+
"error": "Cannot get the keys",
267+
"error_description": err,
268+
})
269+
}
270+
271+
logoutToken := c.FormValue("logout_token")
272+
token, err := jwt.Parse(logoutToken, func(token *jwt.Token) (interface{}, error) {
273+
return ChooseKeyForIDToken(keys, token)
274+
})
275+
if err != nil {
276+
logger.WithNamespace("oidc").Errorf("Error on jwt.Parse for logout token: %s", err)
277+
return c.JSON(http.StatusBadRequest, echo.Map{
278+
"error": "error on parsing the token",
279+
"error_description": err,
280+
})
281+
}
282+
if !token.Valid {
283+
logger.WithNamespace("oidc").Errorf("Invalid logout token: %#v", token)
284+
return c.JSON(http.StatusBadRequest, echo.Map{
285+
"error": "invalid logout token",
286+
})
287+
}
288+
289+
claims := token.Claims.(jwt.MapClaims)
290+
var inst *instance.Instance
291+
if conf.AllowCustomInstance {
292+
sub, ok := claims["sub"].(string)
293+
if !ok {
294+
logger.WithNamespace("oidc").Errorf("Invalid claims: %#v", claims)
295+
return c.JSON(http.StatusBadRequest, echo.Map{
296+
"error": "invalid claims",
297+
})
298+
}
299+
var instances []*instance.Instance
300+
req := &couchdb.FindRequest{
301+
UseIndex: "by-oidcid",
302+
Selector: mango.And(
303+
mango.Equal("oidc_id", sub),
304+
mango.Equal("context", contextName),
305+
),
306+
Limit: 1,
307+
}
308+
err := couchdb.FindDocs(prefixer.GlobalPrefixer, consts.Instances, req, &instances)
309+
if err != nil || len(instances) == 0 {
310+
return c.JSON(http.StatusBadRequest, echo.Map{
311+
"error": "internal server error",
312+
"error_description": err,
313+
})
314+
}
315+
inst = instances[0]
316+
} else {
317+
domain, ok := claims[conf.UserInfoField].(string)
318+
if !ok {
319+
logger.WithNamespace("oidc").Errorf("Invalid claims: %#v", claims)
320+
return c.JSON(http.StatusBadRequest, echo.Map{
321+
"error": "invalid claims",
322+
})
323+
}
324+
domain = strings.ReplaceAll(domain, "-", "") // We don't want - in cozy instance
325+
domain = strings.ToLower(domain) // The domain is case insensitive
326+
domain = conf.UserInfoPrefix + domain + conf.UserInfoSuffix
327+
instance, err := lifecycle.GetInstance(domain)
328+
if err != nil {
329+
return c.JSON(http.StatusBadRequest, echo.Map{
330+
"error": "internal server error",
331+
"error_description": err,
332+
})
333+
}
334+
inst = instance
335+
}
336+
337+
// TODO use the sid to logout only on the current device
338+
if err := session.DeleteOthers(inst, "all"); err != nil {
339+
inst.Logger().WithNamespace("oidc").Errorf("Cannot delete the session: %s", err)
340+
return c.JSON(http.StatusBadRequest, echo.Map{
341+
"error": "internal server error",
342+
"error_description": err,
343+
})
344+
}
345+
346+
c.Response().Header().Set("Cache-Control", "no-store")
347+
return c.NoContent(http.StatusOK)
348+
}
349+
247350
// AccessToken delivers an access_token and a refresh_token if the client gives
248351
// a valid token for OIDC.
249352
func AccessToken(c echo.Context) error {
@@ -848,6 +951,7 @@ func Routes(router *echo.Group) {
848951
router.GET("/redirect", Redirect)
849952
router.GET("/login", Login, middlewares.NeedInstance)
850953
router.POST("/twofactor", TwoFactor, middlewares.NeedInstance)
954+
router.POST("/:context/logout", Logout)
851955
router.POST("/access_token", AccessToken, middlewares.NeedInstance)
852956
}
853957

web/settings/passphrase.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ func (h *HTTPHandler) registerPassphrase(c echo.Context) error {
125125
}
126126
}
127127

128-
sessionID, err := auth.SetCookieForNewSession(c, session.LongRun)
128+
sessionID, err := auth.SetCookieForNewSession(c, session.LongRun, "")
129129
if err != nil {
130130
return err
131131
}
@@ -304,7 +304,7 @@ func (h *HTTPHandler) updatePassphrase(c echo.Context) error {
304304
_ = sharing.SendPublicKey(inst, params.PublicKey)
305305
}()
306306
if hasSession {
307-
_, _ = auth.SetCookieForNewSession(c, currentSession.Duration())
307+
_, _ = auth.SetCookieForNewSession(c, currentSession.Duration(), "")
308308
}
309309
return c.NoContent(http.StatusNoContent)
310310
}
@@ -348,7 +348,7 @@ func (h *HTTPHandler) updatePassphrase(c echo.Context) error {
348348
if hasSession {
349349
duration = currentSession.Duration()
350350
}
351-
if _, err = auth.SetCookieForNewSession(c, duration); err != nil {
351+
if _, err = auth.SetCookieForNewSession(c, duration, ""); err != nil {
352352
return err
353353
}
354354

web/settings/settings_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func setupRouter(t *testing.T, inst *instance.Instance, svc csettings.Service) *
4646
if err != http.ErrNoCookie {
4747
require.NoError(t, err, "Could not get session cookie")
4848
if cookie.Value == "connected" {
49-
sess, _ := session.New(inst, session.LongRun)
49+
sess, _ := session.New(inst, session.LongRun, "")
5050
context.Set("session", sess)
5151
}
5252
}

0 commit comments

Comments
 (0)