Skip to content

Commit 84a60d7

Browse files
authored
Add OIDC Token Exchange For Org Admin OAuth Access (#4711)
## Context The B2B admin app needs to create shared drives on an organization's Cozy instance directly from the browser. The admin user is authenticated by an external OIDC provider, but the target organization Cozy expects normal Cozy OAuth credentials. #### Simple flow: - Admin frontend calls the target org Cozy directly - Cozy validates the external `id_token` - Cozy creates a normal OAuth client on the org instance - Cozy returns standard OAuth credentials usable by cozy-client #### Changes: - Added POST /auth/token_exchange on the normal API. - Kept /auth/* globally blocked by generic CORS, and added dedicated CORS handling only for /auth/token_exchange. - Restricted token-exchange CORS to origins under the target instance OrgDomain. - Added CSP connect-src allowances for: - api-login-{orgId}.{base-domain} - {orgId}.{base-domain} - {orgId}.{orgDomain} - Implemented token exchange validation: - OIDC signature / issuer / audience checks - org_id must match the target instance - org_role must be owner or admin - Returned a standard OAuth response extended with: - client_id - client_secret - registration_access_token - Bound exchanged OAuth clients to OIDC sid when present, so they can be revoked by backchannel logout.
2 parents b7a9302 + 1cfe7b3 commit 84a60d7

File tree

6 files changed

+960
-28
lines changed

6 files changed

+960
-28
lines changed

docs/auth.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,6 +1049,73 @@ Content-Type: application/json
10491049
}
10501050
```
10511051

1052+
### POST /auth/token_exchange
1053+
1054+
This endpoint exchanges an external OIDC `id_token` for a normal Cozy OAuth
1055+
client and token pair on the target instance.
1056+
1057+
It is intended for browser-based admin applications that authenticate with an
1058+
external identity provider, then need to call Cozy APIs directly on an
1059+
organization instance.
1060+
1061+
The target Cozy instance is the request host. The exchanged `id_token` must:
1062+
1063+
- be signed by the configured OIDC provider
1064+
- match the configured issuer and audience
1065+
- contain an `org_id` claim equal to the target instance organization id
1066+
- contain an `org_role` claim equal to `owner` or `admin`
1067+
1068+
The request body is JSON:
1069+
1070+
- `id_token`, the external OIDC token
1071+
- `scope`, currently limited to `io.cozy.files`
1072+
1073+
Example:
1074+
1075+
```http
1076+
POST /auth/token_exchange HTTP/1.1
1077+
Host: myorg123.example.com
1078+
Content-Type: application/json
1079+
Accept: application/json
1080+
1081+
{
1082+
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InRva2VuLWV4Y2hhbmdlIn0...",
1083+
"scope": "io.cozy.files"
1084+
}
1085+
```
1086+
1087+
Response:
1088+
1089+
```http
1090+
HTTP/1.1 200 OK
1091+
Content-Type: application/json
1092+
Cache-Control: no-store
1093+
Pragma: no-cache
1094+
```
1095+
1096+
```json
1097+
{
1098+
"access_token": "eyJhbGciOiJS",
1099+
"token_type": "bearer",
1100+
"refresh_token": "eyJhbGciOiJS",
1101+
"scope": "io.cozy.files",
1102+
"client_id": "64ce5cb0-bd4c-11e6-880e-b3b7dfda89d3",
1103+
"client_secret": "Oung7oi5",
1104+
"registration_access_token": "reg123"
1105+
}
1106+
```
1107+
1108+
The returned OAuth client is a normal Cozy OAuth client:
1109+
1110+
- `client_id`, `client_secret`, `access_token`, and `refresh_token` can be
1111+
used directly with `cozy-client`
1112+
- `registration_access_token` can be used with
1113+
`DELETE /auth/register/:client-id` to revoke that exchanged client
1114+
1115+
When the external `id_token` contains a `sid` claim, the created OAuth client
1116+
is bound to that upstream OIDC session so it can be revoked by OIDC
1117+
backchannel logout.
1118+
10521119
### POST /auth/session_code
10531120

10541121
This endpoint can be used by the flagship application in order to create a

web/auth/auth.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/cozy/cozy-stack/pkg/config/config"
2020
"github.com/cozy/cozy-stack/pkg/crypto"
2121
"github.com/cozy/cozy-stack/pkg/limits"
22+
"github.com/cozy/cozy-stack/pkg/utils"
2223
"github.com/cozy/cozy-stack/web/middlewares"
2324
"github.com/labstack/echo/v4"
2425
)
@@ -510,6 +511,95 @@ func registerPreflight(c echo.Context) error {
510511
return corsPreflight(echo.POST)(c)
511512
}
512513

514+
func tokenExchangeCORS(c echo.Context) bool {
515+
origin := c.Request().Header.Get(echo.HeaderOrigin)
516+
if origin == "" {
517+
return true
518+
}
519+
inst := middlewares.GetInstance(c)
520+
if !tokenExchangeOriginAllowed(origin, inst) {
521+
return false
522+
}
523+
524+
res := c.Response()
525+
res.Header().Add(echo.HeaderVary, echo.HeaderOrigin)
526+
res.Header().Set(echo.HeaderAccessControlAllowOrigin, origin)
527+
res.Header().Set(echo.HeaderAccessControlAllowCredentials, "true")
528+
return true
529+
}
530+
531+
// Allow CORS from *.org_domain
532+
func tokenExchangeOriginAllowed(origin string, inst *instance.Instance) bool {
533+
if inst == nil || inst.OrgDomain == "" {
534+
return false
535+
}
536+
originHost := utils.ExtractInstanceHost(origin)
537+
if originHost == "" {
538+
return false
539+
}
540+
orgDomain := utils.NormalizeDomain(inst.OrgDomain)
541+
return originHost == orgDomain || strings.HasSuffix(originHost, "."+orgDomain)
542+
}
543+
544+
func tokenExchangePreflight(c echo.Context) error {
545+
if !tokenExchangeCORS(c) {
546+
return c.NoContent(http.StatusForbidden)
547+
}
548+
req := c.Request()
549+
res := c.Response()
550+
res.Header().Add(echo.HeaderVary, echo.HeaderAccessControlRequestMethod)
551+
res.Header().Add(echo.HeaderVary, echo.HeaderAccessControlRequestHeaders)
552+
res.Header().Set(echo.HeaderAccessControlAllowMethods, echo.POST)
553+
res.Header().Set(echo.HeaderAccessControlMaxAge, middlewares.MaxAgeCORS)
554+
if h := req.Header.Get(echo.HeaderAccessControlRequestHeaders); h != "" {
555+
res.Header().Set(echo.HeaderAccessControlAllowHeaders, h)
556+
}
557+
return c.NoContent(http.StatusNoContent)
558+
}
559+
560+
func tokenExchange(c echo.Context) error {
561+
if !tokenExchangeCORS(c) {
562+
return c.JSON(http.StatusForbidden, echo.Map{
563+
"error": "the origin of this application is not allowed",
564+
})
565+
}
566+
c.Response().Header().Set("Cache-Control", "no-store")
567+
c.Response().Header().Set("Pragma", "no-cache")
568+
569+
inst := middlewares.GetInstance(c)
570+
var reqBody tokenExchangeRequest
571+
if err := c.Bind(&reqBody); err != nil {
572+
return c.JSON(http.StatusBadRequest, echo.Map{
573+
"error": "invalid request body",
574+
})
575+
}
576+
reqBody.IDToken = strings.TrimSpace(reqBody.IDToken)
577+
reqBody.Scope = strings.TrimSpace(reqBody.Scope)
578+
if reqBody.IDToken == "" {
579+
return c.JSON(http.StatusBadRequest, echo.Map{
580+
"error": "the id_token parameter is mandatory",
581+
})
582+
}
583+
if reqBody.Scope == "" {
584+
return c.JSON(http.StatusBadRequest, echo.Map{
585+
"error": "the scope parameter is mandatory",
586+
})
587+
}
588+
589+
out, err := executeTokenExchange(c, inst, reqBody)
590+
if err != nil {
591+
var httpErr *echo.HTTPError
592+
if errors.As(err, &httpErr) {
593+
return c.JSON(httpErr.Code, echo.Map{
594+
"error": fmt.Sprint(httpErr.Message),
595+
})
596+
}
597+
return err
598+
}
599+
600+
return c.JSON(http.StatusOK, out)
601+
}
602+
513603
func registerFromWebApp(c echo.Context) error {
514604
res := c.Response()
515605
origin := c.Request().Header.Get(echo.HeaderOrigin)
@@ -683,6 +773,8 @@ func Routes(router *echo.Group) {
683773
authHandler.Register(router.Group("/authorize", noCSRF))
684774

685775
router.POST("/access_token", accessToken)
776+
router.POST("/token_exchange", tokenExchange, middlewares.AcceptJSON, middlewares.ContentTypeJSON)
777+
router.OPTIONS("/token_exchange", tokenExchangePreflight)
686778

687779
// Flagship app
688780
router.POST("/session_code", CreateSessionCode)

0 commit comments

Comments
 (0)