Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -1049,6 +1049,73 @@ Content-Type: application/json
}
```

### POST /auth/token_exchange

This endpoint exchanges an external OIDC `id_token` for a normal Cozy OAuth
client and token pair on the target instance.

It is intended for browser-based admin applications that authenticate with an
external identity provider, then need to call Cozy APIs directly on an
organization instance.

The target Cozy instance is the request host. The exchanged `id_token` must:

- be signed by the configured OIDC provider
- match the configured issuer and audience
- contain an `org_id` claim equal to the target instance organization id
- contain an `org_role` claim equal to `owner` or `admin`

The request body is JSON:

- `id_token`, the external OIDC token
- `scope`, currently limited to `io.cozy.files`

Example:

```http
POST /auth/token_exchange HTTP/1.1
Host: myorg123.example.com
Content-Type: application/json
Accept: application/json

{
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InRva2VuLWV4Y2hhbmdlIn0...",
"scope": "io.cozy.files"
}
```

Response:

```http
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
Pragma: no-cache
```

```json
{
"access_token": "eyJhbGciOiJS",
"token_type": "bearer",
"refresh_token": "eyJhbGciOiJS",
"scope": "io.cozy.files",
"client_id": "64ce5cb0-bd4c-11e6-880e-b3b7dfda89d3",
"client_secret": "Oung7oi5",
"registration_access_token": "reg123"
}
```

The returned OAuth client is a normal Cozy OAuth client:

- `client_id`, `client_secret`, `access_token`, and `refresh_token` can be
used directly with `cozy-client`
- `registration_access_token` can be used with
`DELETE /auth/register/:client-id` to revoke that exchanged client

When the external `id_token` contains a `sid` claim, the created OAuth client
is bound to that upstream OIDC session so it can be revoked by OIDC
backchannel logout.

### POST /auth/session_code

This endpoint can be used by the flagship application in order to create a
Expand Down
92 changes: 92 additions & 0 deletions web/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/cozy/cozy-stack/pkg/config/config"
"github.com/cozy/cozy-stack/pkg/crypto"
"github.com/cozy/cozy-stack/pkg/limits"
"github.com/cozy/cozy-stack/pkg/utils"
"github.com/cozy/cozy-stack/web/middlewares"
"github.com/labstack/echo/v4"
)
Expand Down Expand Up @@ -510,6 +511,95 @@ func registerPreflight(c echo.Context) error {
return corsPreflight(echo.POST)(c)
}

func tokenExchangeCORS(c echo.Context) bool {
origin := c.Request().Header.Get(echo.HeaderOrigin)
if origin == "" {
return true
}
inst := middlewares.GetInstance(c)
if !tokenExchangeOriginAllowed(origin, inst) {
return false
}

res := c.Response()
res.Header().Add(echo.HeaderVary, echo.HeaderOrigin)
res.Header().Set(echo.HeaderAccessControlAllowOrigin, origin)
res.Header().Set(echo.HeaderAccessControlAllowCredentials, "true")
return true
}

// Allow CORS from *.org_domain
func tokenExchangeOriginAllowed(origin string, inst *instance.Instance) bool {
if inst == nil || inst.OrgDomain == "" {
return false
}
originHost := utils.ExtractInstanceHost(origin)
if originHost == "" {
return false
}
orgDomain := utils.NormalizeDomain(inst.OrgDomain)
return originHost == orgDomain || strings.HasSuffix(originHost, "."+orgDomain)
}

func tokenExchangePreflight(c echo.Context) error {
if !tokenExchangeCORS(c) {
return c.NoContent(http.StatusForbidden)
}
req := c.Request()
res := c.Response()
res.Header().Add(echo.HeaderVary, echo.HeaderAccessControlRequestMethod)
res.Header().Add(echo.HeaderVary, echo.HeaderAccessControlRequestHeaders)
res.Header().Set(echo.HeaderAccessControlAllowMethods, echo.POST)
res.Header().Set(echo.HeaderAccessControlMaxAge, middlewares.MaxAgeCORS)
if h := req.Header.Get(echo.HeaderAccessControlRequestHeaders); h != "" {
res.Header().Set(echo.HeaderAccessControlAllowHeaders, h)
}
return c.NoContent(http.StatusNoContent)
}

func tokenExchange(c echo.Context) error {
if !tokenExchangeCORS(c) {
return c.JSON(http.StatusForbidden, echo.Map{
"error": "the origin of this application is not allowed",
})
}
c.Response().Header().Set("Cache-Control", "no-store")
c.Response().Header().Set("Pragma", "no-cache")

inst := middlewares.GetInstance(c)
var reqBody tokenExchangeRequest
if err := c.Bind(&reqBody); err != nil {
return c.JSON(http.StatusBadRequest, echo.Map{
"error": "invalid request body",
})
}
reqBody.IDToken = strings.TrimSpace(reqBody.IDToken)
reqBody.Scope = strings.TrimSpace(reqBody.Scope)
if reqBody.IDToken == "" {
return c.JSON(http.StatusBadRequest, echo.Map{
"error": "the id_token parameter is mandatory",
})
}
if reqBody.Scope == "" {
return c.JSON(http.StatusBadRequest, echo.Map{
"error": "the scope parameter is mandatory",
})
}

out, err := executeTokenExchange(c, inst, reqBody)
if err != nil {
var httpErr *echo.HTTPError
if errors.As(err, &httpErr) {
return c.JSON(httpErr.Code, echo.Map{
"error": fmt.Sprint(httpErr.Message),
})
}
return err
}

return c.JSON(http.StatusOK, out)
}

func registerFromWebApp(c echo.Context) error {
res := c.Response()
origin := c.Request().Header.Get(echo.HeaderOrigin)
Expand Down Expand Up @@ -683,6 +773,8 @@ func Routes(router *echo.Group) {
authHandler.Register(router.Group("/authorize", noCSRF))

router.POST("/access_token", accessToken)
router.POST("/token_exchange", tokenExchange, middlewares.AcceptJSON, middlewares.ContentTypeJSON)
router.OPTIONS("/token_exchange", tokenExchangePreflight)

// Flagship app
router.POST("/session_code", CreateSessionCode)
Expand Down
Loading
Loading