Skip to content

Commit 2bbe20d

Browse files
authored
feat: Store OIDC session ID in delegated code flow (#4633)
Start storing an OIDC session identifier (sid) with delegated codes so the session is available when flagship apps exchange their codes for Cozy tokens.
2 parents 0be43f1 + dffc2c2 commit 2bbe20d

File tree

6 files changed

+225
-164
lines changed

6 files changed

+225
-164
lines changed

docs/admin.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1051,6 +1051,11 @@ The cloudery sends its access_token for the OIDC provider, the stack can use it
10511051
to make a request to the userinfo endpoint of the OIDC provider. With the
10521052
response, the stack can create a delegated code associated to the sub.
10531053

1054+
If an `id_token` is also provided, the stack extracts the session ID (`sid`)
1055+
claim and stores it with the delegated code. This session ID is later used for
1056+
OIDC logout: when the user signs out of the flagship app, the stack calls the
1057+
OpenID provider's `end_session_endpoint` to terminate the upstream SSO session.
1058+
10541059
```http
10551060
POST /oidc/dev/franceconnect/code HTTP/1.1
10561061
Accept: application/json
@@ -1060,7 +1065,8 @@ Authorization: Bearer ZmE2ZTFmN
10601065

10611066
```json
10621067
{
1063-
"access_token": "ZmE2ZTFmN"
1068+
"access_token": "ZmE2ZTFmN",
1069+
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
10641070
}
10651071
```
10661072

docs/delegated-auth.md

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -299,15 +299,17 @@ If `id_token_jwk_url` option is set, the client can send an `id_token` instead
299299
of an `oidc_token` in the payload.
300300

301301
If the flagship makes the request, it also can use a delegated code obtained
302-
from the cloudery, by using `code` instead of `oidc_token`.
303-
304-
Flagship clients must include an `id_token` bearing a `sid` claim, even when
305-
they use a delegated code. Cozy keeps that session identifier on the OAuth
306-
client and, when the user signs out of the flagship app (which deletes the
307-
client), the stack performs a best-effort call to the OpenID provider's
308-
`end_session_endpoint` so the upstream SSO session is closed too. This
309-
complements the back-channel logout endpoint (`POST /oidc/:context/logout`)
310-
that the provider can call to terminate Cozy sessions.
302+
from the cloudery, by using `code` instead of `oidc_token`. When using a
303+
delegated code, the session ID (`sid`) is already stored with the code (the
304+
cloudery provides the `id_token` when requesting the delegated code), so the
305+
flagship app does not need to send an `id_token` separately.
306+
307+
Cozy keeps the session identifier on the OAuth client and, when the user signs
308+
out of the flagship app (which deletes the client), the stack performs a
309+
best-effort call to the OpenID provider's `end_session_endpoint` so the
310+
upstream SSO session is closed too. This complements the back-channel logout
311+
endpoint (`POST /oidc/:context/logout`) that the provider can call to terminate
312+
Cozy sessions.
311313

312314
**Note:** if the OAuth client asks for a `*` scope and has not been certified
313315
as the flagship app, this request will return:

docs/flagship.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,16 @@ endpoint to get access to the Cozy.
7373

7474
## SSO logout integration
7575

76-
When the flagship app authenticates via OpenID Connect, it must send an ID
77-
token that contains the `sid` claim when it exchanges its delegated code at
78-
`POST /oidc/access_token`. Cozy stores that session identifier in the OAuth
79-
client. When the user signs out of the flagship app and the client is removed,
80-
the stack calls the identity provider's `end_session_endpoint` (when one is
81-
declared in the provider configuration) so the upstream SSO session is closed
82-
as well. This keeps the Cozy session and the external provider in sync during
83-
logout.
76+
When the flagship app authenticates via OpenID Connect, the session ID (`sid`)
77+
is obtained from the delegated code. The cloudery sends the `id_token` when
78+
requesting the delegated code from the stack, and the stack extracts and stores
79+
the `sid` claim with the code. When the flagship app exchanges the delegated
80+
code at `POST /oidc/access_token`, the session ID is automatically retrieved
81+
and stored in the OAuth client. When the user signs out of the flagship app and
82+
the client is removed, the stack calls the identity provider's
83+
`end_session_endpoint` (when one is declared in the provider configuration) so
84+
the upstream SSO session is closed as well. This keeps the Cozy session and the
85+
external provider in sync during logout.
8486

8587
## Manual certification
8688

web/oidc/oidc.go

Lines changed: 88 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,20 @@ var (
4343
ErrIdentityProvider = errors.New("error from the identity provider")
4444
)
4545

46+
// extractSessionID extracts the session ID (sid) from an id_token.
47+
func extractSessionID(idToken string) string {
48+
if idToken == "" {
49+
return ""
50+
}
51+
claims := jwt.MapClaims{}
52+
_, _, _ = jwt.NewParser().ParseUnverified(idToken, claims)
53+
sid, _ := claims["sid"].(string)
54+
if sid != "" {
55+
logger.WithNamespace("oidc").Debugf("Extracted session ID from id_token: %s", sid)
56+
}
57+
return sid
58+
}
59+
4660
// Start is the route to start the OpenID Connect dance.
4761
func Start(c echo.Context) error {
4862
inst := middlewares.GetInstance(c)
@@ -195,12 +209,13 @@ func Redirect(c echo.Context) error {
195209
if err != nil {
196210
return renderError(c, nil, http.StatusBadRequest, "No OpenID Connect is configured.")
197211
}
198-
accessToken, err := getToken(conf, c.QueryParam("code"))
212+
// Use getToken to get both access_token and id_token
213+
tokenResp, err := getToken(conf, c.QueryParam("code"))
199214
if err != nil {
200215
logger.WithNamespace("oidc").Errorf("Error on getToken: %s", err)
201216
return renderError(c, nil, http.StatusBadGateway, "Error from the identity provider.")
202217
}
203-
params, err := getUserInfo(conf, accessToken)
218+
params, err := getUserInfo(conf, tokenResp.AccessToken)
204219
if err != nil {
205220
return err
206221
}
@@ -220,7 +235,9 @@ func Redirect(c echo.Context) error {
220235
return renderError(c, nil, http.StatusNotFound, "Sorry, the twake was not found.")
221236
}
222237

223-
code := getStorage().CreateCode(sub)
238+
// Extract session ID from id_token for OIDC logout support
239+
sessionID := extractSessionID(tokenResp.IDToken)
240+
code := getStorage().CreateCodeData(sub, sessionID)
224241
u, err := url.Parse(state.Redirect)
225242
if err != nil {
226243
return renderError(c, nil, http.StatusNotFound, "Sorry, an error occurred.")
@@ -307,11 +324,15 @@ func Login(c echo.Context) error {
307324
}
308325

309326
var token string
327+
// Track the id_token separately for SID extraction (used for OIDC logout)
328+
var idTokenForSID string
329+
310330
if idToken != "" && conf.IDTokenKeyURL != "" {
311331
if err := checkIDToken(conf, inst, idToken); err != nil {
312332
return renderError(c, inst, http.StatusBadRequest, err.Error())
313333
}
314334
token = idToken
335+
idTokenForSID = idToken
315336
} else {
316337
if conf.AllowOAuthToken {
317338
token = c.QueryParam("access_token")
@@ -321,11 +342,14 @@ func Login(c echo.Context) error {
321342
return renderError(c, nil, http.StatusNotFound, "Sorry, the session has expired.")
322343
}
323344
code := c.QueryParam("code")
324-
token, err = getToken(conf, code)
345+
// Use getToken to get both access_token and id_token
346+
tokenResp, err := getToken(conf, code)
325347
if err != nil {
326348
logger.WithNamespace("oidc").Errorf("Error on getToken: %s", err)
327349
return renderError(c, inst, http.StatusBadGateway, "Error from the identity provider.")
328350
}
351+
token = tokenResp.AccessToken
352+
idTokenForSID = tokenResp.IDToken
329353
if state.SharingID != "" {
330354
return acceptSharing(c, inst, conf, token, state)
331355
}
@@ -347,9 +371,8 @@ func Login(c echo.Context) error {
347371
}
348372
}
349373

350-
claims := jwt.MapClaims{}
351-
_, _, _ = jwt.NewParser().ParseUnverified(token, claims)
352-
sid, _ := claims["sid"].(string)
374+
// Extract SID from id_token (not access_token) for OIDC logout support
375+
sid := extractSessionID(idTokenForSID)
353376
return createSessionAndRedirect(c, inst, redirect, confirm, sid)
354377
}
355378

@@ -548,6 +571,39 @@ func Logout(c echo.Context) error {
548571
return c.NoContent(http.StatusOK)
549572
}
550573

574+
// validateDelegatedCode validates a delegated code and returns the associated session ID.
575+
// Returns an error if the code is invalid or doesn't match the instance.
576+
func validateDelegatedCode(inst *instance.Instance, code string) (string, error) {
577+
codeData := getStorage().GetCodeData(code)
578+
if codeData == nil || codeData.Sub == "" {
579+
return "", errors.New("invalid code")
580+
}
581+
582+
sub := codeData.Sub
583+
if sub != inst.OIDCID && sub != inst.FranceConnectID && sub != inst.Domain {
584+
inst.Logger().WithNamespace("oidc").Infof("AccessToken invalid code: %s (%s - %s - %s)",
585+
sub, inst.OIDCID, inst.FranceConnectID, inst.Domain)
586+
return "", errors.New("invalid code")
587+
}
588+
589+
return codeData.SessionID, nil
590+
}
591+
592+
// validateOIDCToken validates an OIDC token (id_token or access_token) for the instance.
593+
func validateOIDCToken(inst *instance.Instance, idToken, oidcToken string) error {
594+
conf, err := getGenericConfig(inst.ContextName)
595+
if err != nil || !conf.AllowOAuthToken {
596+
return errors.New("this endpoint is not enabled")
597+
}
598+
599+
if idToken != "" {
600+
err = checkIDToken(conf, inst, idToken)
601+
} else {
602+
err = checkDomainFromUserInfo(conf, inst, oidcToken)
603+
}
604+
return err
605+
}
606+
551607
// AccessToken delivers an access_token and a refresh_token if the client gives
552608
// a valid token for OIDC.
553609
func AccessToken(c echo.Context) error {
@@ -566,33 +622,18 @@ func AccessToken(c echo.Context) error {
566622
return err
567623
}
568624

625+
// Validate the delegated code or OIDC token
626+
var codeSessionID string
569627
if reqBody.Code != "" {
570-
sub := getStorage().GetSub(reqBody.Code)
571-
invalidCode := sub == ""
572-
if sub != inst.OIDCID && sub != inst.FranceConnectID && sub != inst.Domain {
573-
invalidCode = true
574-
}
575-
if invalidCode {
576-
inst.Logger().WithNamespace("oidc").Infof("AccessToken invalid code: %s (%s - %s - %s)",
577-
sub, inst.OIDCID, inst.FranceConnectID, inst.Domain)
628+
sessionID, err := validateDelegatedCode(inst, reqBody.Code)
629+
if err != nil {
578630
return c.JSON(http.StatusBadRequest, echo.Map{
579631
"error": "invalid code",
580632
})
581633
}
634+
codeSessionID = sessionID
582635
} else {
583-
conf, err := getGenericConfig(inst.ContextName)
584-
if err != nil || !conf.AllowOAuthToken {
585-
return c.JSON(http.StatusBadRequest, echo.Map{
586-
"error": "this endpoint is not enabled",
587-
})
588-
}
589-
// Check the token from the remote URL.
590-
if reqBody.IDToken != "" {
591-
err = checkIDToken(conf, inst, reqBody.IDToken)
592-
} else {
593-
err = checkDomainFromUserInfo(conf, inst, reqBody.OIDCToken)
594-
}
595-
if err != nil {
636+
if err := validateOIDCToken(inst, reqBody.IDToken, reqBody.OIDCToken); err != nil {
596637
return c.JSON(http.StatusBadRequest, echo.Map{
597638
"error": err.Error(),
598639
})
@@ -658,27 +699,19 @@ func AccessToken(c echo.Context) error {
658699
}
659700
}
660701

661-
// Store the session ID in the client for logout purposes
662-
if client.Flagship {
663-
if reqBody.IDToken != "" {
664-
// Extract SID from the ID token
665-
claims := jwt.MapClaims{}
666-
_, _, _ = jwt.NewParser().ParseUnverified(reqBody.IDToken, claims)
667-
if sid, ok := claims["sid"].(string); ok && sid != "" {
668-
client.OIDCSessionID = sid
669-
} else {
670-
logger.WithNamespace("oidc").Warnf("No session ID found in ID token")
671-
return c.JSON(http.StatusBadRequest, echo.Map{
672-
"error": "No session ID found in ID token",
673-
})
674-
}
675-
} else {
676-
logger.WithNamespace("oidc").Warnf("No ID token Logout won't work")
677-
}
702+
// Store the session ID in the client for logout purposes (priority: delegated code > id_token)
703+
sessionID := codeSessionID
704+
if sessionID == "" {
705+
sessionID = extractSessionID(reqBody.IDToken)
706+
}
707+
if sessionID != "" {
708+
client.OIDCSessionID = sessionID
709+
logger.WithNamespace("oidc").Debugf("Using session ID for OIDC logout: %s", sessionID)
678710
}
679711

680-
// Remove the pending flag on the OAuth client (if needed)
681-
if client.Pending {
712+
// Update the OAuth client if needed (pending flag or new session ID)
713+
needsUpdate := client.Pending || sessionID != ""
714+
if needsUpdate {
682715
client.Pending = false
683716
client.ClientID = ""
684717
_ = couchdb.UpdateDoc(inst, client)
@@ -907,15 +940,7 @@ type tokenResponse struct {
907940
IDToken string `json:"id_token"`
908941
}
909942

910-
func getToken(conf *Config, code string) (string, error) {
911-
resp, err := getTokenWithIDToken(conf, code)
912-
if err != nil {
913-
return "", err
914-
}
915-
return resp.AccessToken, nil
916-
}
917-
918-
func getTokenWithIDToken(conf *Config, code string) (*tokenResponse, error) {
943+
func getToken(conf *Config, code string) (*tokenResponse, error) {
919944
data := url.Values{
920945
"grant_type": []string{"authorization_code"},
921946
"code": []string{code},
@@ -1251,7 +1276,8 @@ func Routes(router *echo.Group) {
12511276

12521277
// GetDelegatedCode is mostly a proxy for the userinfo request made by the
12531278
// cloudery to the OIDC provider. It adds a delegated code in the response
1254-
// associated to the sub.
1279+
// associated to the sub. If an id_token is provided, the session ID (sid) is
1280+
// extracted and stored with the delegated code for later use in OIDC logout.
12551281
func GetDelegatedCode(c echo.Context) error {
12561282
contextName := c.Param("context")
12571283
provider := c.Param("provider")
@@ -1270,6 +1296,7 @@ func GetDelegatedCode(c echo.Context) error {
12701296

12711297
var reqBody struct {
12721298
AccessToken string `json:"access_token"`
1299+
IDToken string `json:"id_token"`
12731300
}
12741301
if err := c.Bind(&reqBody); err != nil {
12751302
return err
@@ -1297,8 +1324,11 @@ func GetDelegatedCode(c echo.Context) error {
12971324
s = domain
12981325
}
12991326

1327+
// Extract session ID (sid) from id_token if provided
1328+
sessionID := extractSessionID(reqBody.IDToken)
1329+
13001330
logger.WithNamespace("oidc").Infof("GetDelegatedCode for %s", s)
1301-
params["delegated_code"] = getStorage().CreateCode(s)
1331+
params["delegated_code"] = getStorage().CreateCodeData(s, sessionID)
13021332
return c.JSON(http.StatusOK, params)
13031333
}
13041334

0 commit comments

Comments
 (0)