diff --git a/docs/adr/004-oidc-token_ttl_improvements.md b/docs/adr/004-oidc-token_ttl_improvements.md new file mode 100644 index 00000000000..136863f8282 --- /dev/null +++ b/docs/adr/004-oidc-token_ttl_improvements.md @@ -0,0 +1,177 @@ +# ADR: OIDC Token TTL Improvements + +## Status + +**Proposed** + +## Context + +In multi-application deployments where Twake Workplace serves applications (like Twake Mail) in iframes, +there is a fundamental disconnect between cozy-stack session management and OIDC token validity. + +#### Real-World Scenario: pommes.fr Deployment + +``` +Domain Structure: +├── auth.pommes.fr ← OIDC Provider +└── *.workplace.pommes.fr ← Twake Workplace apps + ├── home.workplace.pommes.fr + └── Twake Mail.workplace.pommes.fr (iframe) +``` + +**Problem Flow:** + +```mermaid +sequenceDiagram + participant User + participant Cozy as Cozy Home + participant Twake Mail as Twake Mail (iframe) + participant Stack as Cozy Stack + participant OIDC + + User->>Cozy: Login via OIDC + Cozy->>OIDC: Authenticate + OIDC-->>Cozy: Tokens (TTL: 1 hour) + Cozy->>Stack: Create session + Stack-->>Cozy: Session (TTL: 30 days) + + Note over User,OIDC: Time passes... OIDC token expires + + User->>Twake Mail: Open Twake Mail in iframe + Twake Mail->>Stack: API request + Stack-->>Twake Mail: 401 Unauthorized + Twake Mail->>OIDC: Redirect to login + OIDC--xTwake Mail: ❌ Blocked! CSP doesn't allow iframe + + Note over Twake Mail: User stuck - can't authenticate +``` + +#### Root Cause + +Cozy-stack sessions (30 days) are completely independent of OIDC token validity (typically 1 day) + +#### Impact + +- Users appear logged into Cozy but embedded apps fail silently +- OIDC provider login page cannot load in iframe (CSP `frame-ancestors` restriction) +- Poor user experience with no clear path to resolution + + +### Current Implementation + +The cozy-stack currently supports OpenID Connect (OIDC) for delegated authentication. When a user authenticates via OIDC, the stack: + +1. Exchanges the authorization code for tokens (`access_token`, `id_token`, optionally `refresh_token`) +2. Uses the `access_token` once to call the UserInfo endpoint +3. Extracts the `sid` (session ID) from the `id_token` for logout support +4. Discards all OIDC tokens immediately after use +5. Issues its own cozy-stack OAuth tokens to the client + +The current token response parsing only captures two fields: + +```go +type tokenResponse struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` +} +``` + +### Current Limitations + +| Limitation | Impact | +|------------|--------| +| OIDC `refresh_token` not captured | Cannot refresh OIDC access token when it expires | +| OIDC `expires_in` not captured | No awareness of token TTL | +| OIDC `access_token` not stored | Cannot make subsequent calls to OIDC-protected APIs on behalf of user | +| Session TTL independent of OIDC | Sessions valid long after OIDC token expires | +| No lifecycle validation | Token validity not checked after initial auth | +| Back-channel logout deletes ALL sessions | Should use `sid` for per-device logout | + + +## Proposal + +### Extend Token Response Parsing + +Update the `tokenResponse` struct to capture all relevant fields: + +```go +type tokenResponse struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresIn int64 `json:"expires_in,omitempty"` + TokenType string `json:"token_type,omitempty"` +} +``` + +#### Store OIDC Tokens on OAuth Client + +Extend the `oauth.Client` struct to store OIDC tokens: + +```go +type Client struct { + // ... existing fields ... + + // OIDC token storage + OIDCSessionID string `json:"oidc_session_id,omitempty"` // Existing + OIDCAccessToken string `json:"oidc_access_token,omitempty"` // New + OIDCRefreshToken string `json:"oidc_refresh_token,omitempty"` // New + OIDCTokenExpiresAt *time.Time `json:"oidc_token_expires_at,omitempty"` // New +} +``` + +#### Store OIDC Token Expiry on Session + +Extend the `session.Session` struct: + +```go +type Session struct { + // ... existing fields ... + SID string `json:"sid,omitempty"` // Existing + OIDCTokenExpiresAt *time.Time `json:"oidc_token_expires_at,omitempty"` // New +} +``` + +### Session TTL Alignment + +#### Session Validity Middleware + +Add HTTP middleware that checks OIDC token expiry on each request: +if the token is expired, return a 401 Unauthorized response with an `oidc_token_expired` error indicating re-authentication is required. + +### Back-Channel Logout Fix + +Per-Device Logout Using SID + +### Extended Configuration Options + +```yaml +authentication: + the-context-name: + oidc: + # ... existing config ... + + # Token storage (Part A) + store_tokens: true # Enable OIDC token storage (default: false) + + # Session alignment (Part C) + align_session_ttl: true # Tie session validity to OIDC token (default: false) +``` + +## Alternatives + +### Store Tokens in Session Instead of OAuth Client + +Store OIDC tokens in the `session.Session` document instead of `oauth.Client`. + + +### Encrypt Stored Tokens + +Encrypt OIDC tokens before storing in CouchDB. + + +## Decision + +## Consequences + +Desktop/Mobile apps will have token with the same short TTL \ No newline at end of file diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 00000000000..a760b7e90cf --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,32 @@ +# Architecture Decision Records (ADR) + +This directory contains Architecture Decision Records (ADRs) for the cozy-stack project. + +## What is an ADR? + +An Architecture Decision Record (ADR) is a document that captures an important architectural decision made along with its context and consequences. + +## ADR Template + +Each ADR should include: + +- **Status**: Proposed, Accepted, Deprecated, or Superseded +- **Context**: The situation and problem we are facing +- **Proposal**: The proposed solution +- **Alternatives**: Other options considered +- **Decision**: The final decision and rationale +- **Consequences**: The resulting effects (positive and negative) + +## Index + +| ADR | Title | Status | +|-----|-------|--------| +| [004](004-oidc-token_ttl_improvements.md) | OIDC Token Management Improvements | Proposed | + +## Creating a New ADR + +1. Copy the template or an existing ADR +2. Use the next sequential number: `NNNN-short-title.md` +3. Fill in all sections +4. Update this README index +5. Submit for review diff --git a/docs/delegated-auth.md b/docs/delegated-auth.md index 69ffcfe57af..ae9350c62c1 100644 --- a/docs/delegated-auth.md +++ b/docs/delegated-auth.md @@ -1,12 +1,29 @@ [Table of contents](README.md#table-of-contents) -# Delegated authentication +# Delegated Authentication In general, the cozy stack manages the authentication itself. In some cases, an -integration with other softwares can be mandatory. It's possible to use JWT or +integration with other software can be mandatory. It's possible to use JWT or OpenID Connect, with a bit of configuration to do that. -## JWT +## Table of Contents + +- [JWT Authentication](#jwt-authentication) +- [OpenID Connect](#openid-connect) + - [Configuration](#configuration) + - [Architecture Overview](#architecture-overview) + - [Authentication Flows](#authentication-flows) + - [Session Management](#session-management) + - [Token Handling](#token-handling) + - [Logout Mechanisms](#logout-mechanisms) + - [Routes Reference](#routes-reference) + - [Admin Routes](#admin-routes) +- [FranceConnect](#franceconnect) +- [Sharing with OIDC](#sharing-with-oidc) + +--- + +## JWT Authentication To enable an external system to create links with a JWT to log in users for cozy instances in a given context, we just need to add the secret to use for @@ -22,14 +39,19 @@ The external system can then create a JWT, with the parameter `name` as the instance domain, and send the user to `https:///?jwt=...`. The user will be logged in, and redirected to its default application. -## Open ID Connect +--- + +## OpenID Connect -OpenID Connect can also be used, and is more adapted when the users don't -always come from the authentication provider. +OpenID Connect (OIDC) can be used for delegated authentication, and is more +adapted when the users don't always come from the authentication provider. +The stack supports generic OIDC providers (such as Keycloak, LemonLDAP, etc.) +and FranceConnect. -For OpenID Connect, there are more configuration parameters and they must be -configured per context. A context is set of configuration parameters and each -Cozy instance belongs to one context. +### Configuration + +OIDC configuration is set per context. A context is a set of configuration +parameters and each Cozy instance belongs to one context. ```yaml authentication: @@ -41,6 +63,7 @@ authentication: scope: openid profile login_domain: login.mycozy.cloud redirect_uri: https://oauthcallback.mycozy.cloud/oidc/redirect + logout_url: https://identity-provider/logout authorize_url: https://identity-provider/path/to/authorize token_url: https://identity-provider/path/to/token userinfo_url: https://identity-provider/path/to/userinfo @@ -52,52 +75,350 @@ authentication: id_token_jwk_url: https://identity-provider/path/to/jwk ``` -Let's see what it means: - -- `disable_password_authentication` can be set to `true` to disable the classic - password authentication on the Cozy, and forces the user to login with OpenID - Connect. - -And in the `oidc` section, we have: - -- `client_id` and `client_secret` are the OAuth client that will be used to - talk to the identity provider -- `scope` is the OAuth scope parameter (it is often `openid profile`) -- `login_domain` is a domain that is not tied to an instance, but allows to - login with OIDC with the provider configured on this context -- `redirect_uri` is where the user will be redirected by the identity provider - after login (it must often be declared when creating the OAuth client, and we - have to use a static hostname, not the hostname of a cozy instance) -- `logout_url` can be set to redirect the user to this URL after they have been - logged out -- `token_url`, `authorize_url`, and `userinfo_url` are the URLs used to talk to - the identity provider (they ofter can be found by the discovery mechanism of - OpenID Connect with the names `token_endpoint`, `authorization_endpoint`, and - `userinfo_endpoint`) -- `userinfo_instance_field` is the JSON field to use in the UserInfo response - to know the cozy instance of the logged in user. -- `userinfo_instance_prefix` and `userinfo_instance_suffix` are optional, and - will be put before and after the field fetched from the UserInfo response to - give the complete instance URL -- `allow_custom_instance` can be set to true to let the user chooses their - instance name -- `allow_oauth_token` must be set to true to enable the - `POST /oidc/access_token` route (see below for more details). - -With the example config, if the UserInfo response contains `"cozy_number": -"00001"`, the user can login on the instance `name00001.mycozy.cloud`. - -When `allow_custom_instance` is set to true, the stack will look at the `sub` -field in the UserInfo response, and checks that it matches the `oidc_id` set -on this instance (and the `userinfo_instance_*` and `login_domain` fields are -ignored). If `id_token_jwk_url` is set, the client can send the ID token from -the provider instead of the access token. This token will be checked with the -key fetched from this URL, and the `sub` field of it must match the `oidc_id` -set in the instance. +#### Configuration Parameters + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `client_id` | Yes | OAuth client ID for talking to the identity provider | +| `client_secret` | Yes | OAuth client secret | +| `scope` | Yes | OAuth scope parameter (typically `openid profile`) | +| `redirect_uri` | Yes | Where the user will be redirected after login. Must be a static hostname, not a cozy instance hostname. Must be registered with the OIDC provider | +| `authorize_url` | Yes | Authorization endpoint URL (`authorization_endpoint` in OIDC discovery) | +| `token_url` | Yes | Token endpoint URL (`token_endpoint` in OIDC discovery) | +| `userinfo_url` | Yes | UserInfo endpoint URL (`userinfo_endpoint` in OIDC discovery) | +| `login_domain` | No | A domain not tied to an instance that allows login with OIDC | +| `logout_url` | No | URL to redirect the user after logout | +| `userinfo_instance_field` | No | JSON field in UserInfo response to determine the cozy instance | +| `userinfo_instance_prefix` | No | Prefix added before the userinfo field value | +| `userinfo_instance_suffix` | No | Suffix added after the userinfo field value | +| `allow_custom_instance` | No | If true, uses `sub` claim to match instance's `oidc_id` | +| `allow_oauth_token` | No | If true, enables `POST /oidc/access_token` route | +| `id_token_jwk_url` | No | URL to fetch JWK keys for validating ID tokens and logout tokens | + +#### Context-level Parameters + +| Parameter | Description | +|-----------|-------------| +| `disable_password_authentication` | If true, disables password login and forces OIDC | + +#### Instance Identification + +The stack determines which Cozy instance a user belongs to in two ways: + +1. **UserInfo field mapping** (default): The stack reads `userinfo_instance_field` + from the UserInfo response and constructs the domain as: + `{prefix}{field_value}{suffix}` + + Example: If UserInfo returns `"cozy_number": "00001"` with prefix `name` and + suffix `.mycozy.cloud`, the instance is `name00001.mycozy.cloud`. + +2. **Custom instance mode** (`allow_custom_instance: true`): The stack matches + the `sub` claim from UserInfo/ID token against the `oidc_id` field stored + on the instance. The `userinfo_instance_*` and `login_domain` fields are + ignored in this mode. + +--- + +### Architecture Overview + +The OIDC implementation involves several components: + +```mermaid +sequenceDiagram + participant Browser as Browser/App + participant Stack as Cozy Stack + participant Cloudery as Cloudery (optional) + participant OIDC as OIDC Provider + + Browser->>Stack: 1. Start OIDC + Stack-->>Browser: 2. Redirect to Provider + Browser->>OIDC: 3. Authenticate + OIDC-->>Browser: 4. Redirect with auth code + Browser->>Stack: 5. Code to Stack + Stack->>OIDC: 6. Exchange code for tokens + OIDC-->>Stack: 7. access_token + id_token + Stack->>OIDC: 8. Get UserInfo + OIDC-->>Stack: 9. User claims + Stack-->>Browser: 10. Session cookie +``` + +#### Key Components -### Routes +| Component | Description | +|-----------|-------------| +| **State Storage** | Stores OIDC state and nonce during the dance (Redis or in-memory) | +| **Delegated Code Storage** | Stores delegated codes for Cloudery/flagship flow (Redis or in-memory) | +| **Session** | CouchDB document with optional `sid` field from OIDC | +| **OAuth Client** | CouchDB document with optional `oidc_session_id` field | + +#### State Storage + +During the OIDC dance, the stack stores state information to prevent CSRF and +replay attacks: + +- **Storage**: Redis (if configured) or in-memory +- **TTL**: 15 minutes +- **Contents**: instance domain, redirect URL, nonce, confirm flag, OIDC context, sharing info + +#### Delegated Code Storage + +For the Cloudery/flagship flow, the stack stores delegated codes: + +- **Storage**: Redis (if configured) or in-memory +- **TTL**: 3 hours +- **Contents**: subject (`sub` or domain), session ID (`sid` from id_token) + +--- + +### Authentication Flows + +#### Flow 1: Web Browser Login + +This is the standard OIDC Authorization Code flow for web browsers. + +```mermaid +sequenceDiagram + participant User as User Browser + participant Stack as Cozy Stack + participant OIDC as OIDC Provider + + User->>Stack: GET /oidc/start + Stack-->>User: 302 to authorize_url (with state, nonce) + User->>OIDC: User authenticates + OIDC-->>User: 302 to redirect_uri (with code, state) + User->>Stack: GET /oidc/redirect?code=xxx&state=yyy + Stack->>OIDC: POST token_url (exchange code) + OIDC-->>Stack: {access_token, id_token} + Stack->>OIDC: GET userinfo_url + OIDC-->>Stack: {sub, domain, ...} + Stack-->>User: 302 to instance /oidc/login + User->>Stack: GET /oidc/login?state=yyy + Stack-->>User: Set-Cookie, 302 to home app +``` + +**State transitions:** + +1. `/oidc/start` - Creates state holder, redirects to OIDC provider +2. `/oidc/redirect` - Validates state, exchanges code for tokens, extracts SID from id_token +3. `/oidc/login` - Creates session with SID, sets cookie + +#### Flow 2: Flagship App via Cloudery + +The flagship app uses the Cloudery as an intermediary to obtain a delegated code. +This flow involves two different `client_secret` values: + +| Secret | Issued By | Used By | Purpose | +|--------|-----------|---------|---------| +| OIDC `client_secret` | OIDC Provider | Cloudery/Stack | Authenticate to OIDC provider | +| OAuth `client_secret` | Cozy-Stack | Flagship App | Authenticate to Cozy-Stack | + +```mermaid +sequenceDiagram + participant Flagship as Flagship App + participant Instance as Stack (Instance) + participant Cloudery as Cloudery + participant Admin as Stack (Admin) + participant OIDC as OIDC Provider + + Note over Flagship,Instance: 1. OAuth Client Registration + Flagship->>Instance: POST /auth/register
{client_name, redirect_uris, software_id} + Instance-->>Flagship: {client_id, client_secret, ...} + + Note over Flagship,Instance: 2. Client Certification (optional) + Flagship->>Instance: POST /auth/clients/:id/challenge + Instance-->>Flagship: {nonce} + Flagship->>Instance: POST /auth/clients/:id/attestation + Instance-->>Flagship: 204 OK (certified) + + Note over Flagship,OIDC: 3. OIDC Authentication via Cloudery + Flagship->>Cloudery: Auth request + Cloudery->>OIDC: OIDC dance (using OIDC client_secret) + OIDC-->>Cloudery: access_token + id_token + Cloudery->>Admin: POST /oidc/:ctx/:provider/code
{access_token, id_token} + Admin->>OIDC: GET userinfo_url + OIDC-->>Admin: User claims + Admin-->>Cloudery: {delegated_code, sub, ...} + Cloudery-->>Flagship: {delegated_code, instance} + + Note over Flagship,Instance: 4. Exchange for Cozy-Stack Tokens + Flagship->>Instance: POST /oidc/access_token
{code, client_id, client_secret} + Note right of Instance: Uses OAuth client_secret
from step 1 + Instance-->>Flagship: {access_token, refresh_token}
(cozy-stack tokens) +``` + +**Key points:** + +- **Step 1**: Flagship registers as an OAuth client with cozy-stack and receives its own `client_id` and `client_secret` +- **Step 2**: Flagship optionally certifies itself via Play Integrity (Android) or AppAttest (iOS) +- **Step 3**: Cloudery performs the OIDC dance using the **OIDC provider's** credentials (from stack config) +- **Step 4**: Flagship exchanges the delegated code using its **own** `client_id` and `client_secret` (from step 1) +- The stack extracts the `sid` (session ID) from the id_token and stores it with the delegated code +- The `oidc_session_id` is stored on the OAuth client for logout purposes + +#### Flow 3: Direct OIDC Token Exchange + +When `allow_oauth_token` is enabled, clients can exchange an OIDC token directly +for cozy-stack tokens (without the Cloudery intermediary). + +```mermaid +sequenceDiagram + participant Client as OAuth Client + participant Stack as Cozy Stack + participant OIDC as OIDC Provider + + Client->>Stack: POST /oidc/access_token
{oidc_token, client_id, client_secret} + Stack->>OIDC: GET userinfo_url
Authorization: Bearer {oidc_token} + OIDC-->>Stack: {sub, ...} + Stack-->>Client: {access_token, refresh_token} +``` + +If `id_token_jwk_url` is configured, the client can send an `id_token` instead of +`oidc_token`. The stack validates the JWT signature using keys from the JWK URL. + +--- + +### Session Management + +#### Session Structure + +When a user authenticates via OIDC, a session document is created in CouchDB: + +```json +{ + "_id": "session-id", + "_rev": "...", + "created_at": "2024-01-15T10:00:00Z", + "last_seen": "2024-01-15T10:00:00Z", + "long_run": true, + "sid": "oidc-session-id-from-id-token" +} +``` + +The `sid` field contains the session ID extracted from the OIDC provider's +`id_token` (the `sid` claim). This is used for back-channel logout. + +#### Session ID Extraction + +The stack extracts the `sid` claim from the id_token during: + +1. **Web browser flow**: At `/oidc/redirect` when tokens are received +2. **Flagship flow**: Stored with the delegated code, retrieved at `/oidc/access_token` + +The extraction parses the id_token JWT (without verification, as signature was +already verified) and reads the `sid` claim. + +#### OAuth Client Session Tracking + +For OAuth clients (like the flagship app), the session ID is also stored on +the client document: + +```json +{ + "_id": "client-id", + "client_name": "Cozy Flagship", + "oidc_session_id": "oidc-session-id", + ... +} +``` + +This enables calling the OIDC provider's `end_session_endpoint` when the user +logs out. + +--- + +### Token Handling + +#### Tokens from OIDC Provider + +When the stack exchanges the authorization code at the token endpoint, it +receives: + +| Token | Used For | Stored | +|-------|----------|--------| +| `access_token` | Calling UserInfo endpoint | No (used once, discarded) | +| `id_token` | Extracting `sid` for logout | No (only `sid` extracted) | +| `refresh_token` | Refreshing access_token | No (not captured) | +| `expires_in` | Token TTL | No (not captured) | + +**Current limitation**: The stack only uses the OIDC access_token to fetch +UserInfo immediately, then discards it. The refresh_token and expires_in are +not captured or stored. + +#### Tokens from Cozy Stack -Let's see the 3 routes used in this process +After successful OIDC authentication, the stack issues its own tokens: + +| Token | Audience | Validity | +|-------|----------|----------| +| Access token | `access` | 7 days | +| Refresh token | `refresh` | No expiration | + +These are standard cozy-stack OAuth tokens (JWT format) and are independent +of the OIDC provider's tokens. + +--- + +### Logout Mechanisms + +The stack supports two logout mechanisms for OIDC: + +#### 1. Back-Channel Logout (Provider → Stack) + +The OIDC provider can notify the stack to terminate user sessions by calling +the back-channel logout endpoint. + +```mermaid +sequenceDiagram + participant OIDC as OIDC Provider + participant Stack as Cozy Stack + participant DB as CouchDB + + OIDC->>Stack: POST /oidc/:ctx/logout
logout_token=... + Stack->>Stack: Validate JWT signature
(using id_token_jwk_url) + Stack->>Stack: Extract sub/domain from claims + Stack->>DB: Delete all sessions for instance + Stack-->>OIDC: 200 OK +``` + +**Current behavior**: The back-channel logout deletes ALL sessions for the +user's instance, not just the specific session. The `sid` claim in the +logout_token is not used for per-device logout. + +**Logout token validation**: +- JWT signature verified using keys from `id_token_jwk_url` +- Keys are cached for 24 hours +- Extracts `sub` (for `allow_custom_instance`) or domain field from claims + +#### 2. End-Session Logout (Stack → Provider) + +When an OAuth client is deleted (e.g., user logs out of flagship app), the +stack calls the OIDC provider's `end_session_endpoint` to terminate the SSO +session. + +```mermaid +sequenceDiagram + participant User as User + participant Stack as Cozy Stack + participant OIDC as OIDC Provider + + User->>Stack: Delete OAuth client (logout) + Stack->>OIDC: GET /.well-known/openid-configuration + OIDC-->>Stack: {end_session_endpoint: "..."} + Stack->>OIDC: GET end_session_endpoint?session_id=xxx + OIDC-->>Stack: 200 OK + Stack-->>User: Client deleted +``` + +**Implementation details**: +- Fetches OIDC configuration from `/.well-known/openid-configuration` +- Configuration is cached for 24 hours +- Calls `end_session_endpoint` with `session_id` parameter +- Best-effort: failures don't prevent client deletion +- Only triggered if `oidc_session_id` is set on the OAuth client + +--- + +### Routes Reference #### GET /oidc/start @@ -106,53 +427,66 @@ redirect him/her to the identity provider with the rights parameter. The user will also be redirected here if they are not connected and that the password authentication is disabled. +**Request:** ```http GET /oidc/start HTTP/1.1 Host: name00001.mycozy.cloud ``` +**Response:** ```http HTTP/1.1 303 See Other -Location: https://identity-provider/path/to/authorize?response_type=code&state=9f6873dfce7d&scope=openid+profile&client_id=aClientID&nonce=94246498&redirect_uri=https://oauthcallback.mycozy.cloud/oidc/redirect +Location: https://identity-provider/authorize?response_type=code&state=9f6873dfce7d&scope=openid+profile&client_id=aClientID&nonce=94246498&redirect_uri=https://oauthcallback.mycozy.cloud/oidc/redirect ``` +**Query parameters:** +- `redirect` - URL to redirect after login (optional) +- `confirm` - Confirmation token (optional) + #### GET /oidc/redirect -Then, the user can log in on the identity provider, and then he/she will be -redirected to this URL. Note that the URL is on a generic domain: the stack -will redirect the user to his/her instance (where it's possible to create -cookies to log in the user). +Callback endpoint for the OIDC provider. Handles the authorization code and +redirects to the user's instance. +**Request:** ```http GET /oidc/redirect?state=9f6873dfce7d&code=ccd0032a HTTP/1.1 Host: oauthcallback.mycozy.cloud ``` +**Response:** ```http HTTP/1.1 303 See Other -Location: https://name00001.mycozy.cloud/oidc/login?state=9f6873dfce7d&code=ccd0032a +Location: https://name00001.mycozy.cloud/oidc/login?state=9f6873dfce7d ``` +**Processing:** +1. Validates state exists and hasn't expired +2. Exchanges code for tokens at token_url +3. Extracts SID from id_token +4. Fetches UserInfo to determine instance +5. Redirects to instance's `/oidc/login` + #### GET /oidc/login -On this route, the stack can create the session for the user, with the cookies. +Creates the session and sets cookies on the user's instance. +**Request:** ```http -GET /oidc/login?code=ccd0032a HTTP/1.1 +GET /oidc/login?state=9f6873dfce7d HTTP/1.1 Host: name00001.mycozy.cloud ``` +**Response:** ```http HTTP/1.1 303 See Other -Set-Cookie: ... +Set-Cookie: cozysessid=...; Path=/; Domain=.name00001.mycozy.cloud; HttpOnly; Secure Location: https://name00001-home.mycozy.cloud/ ``` -If the `allow_oauth_token` option is enabled, it's possible to use an -access_token instead of code on this URL. - -If the `id_token_jwk_url` option is enabled, it's possible to use an -id_token instead. +**Alternative parameters:** +- `access_token` - OIDC access token (if `allow_oauth_token` is enabled) +- `id_token` - OIDC ID token (if `id_token_jwk_url` is configured) #### POST /oidc/twofactor @@ -163,6 +497,7 @@ device token is set, a session will be created. Else, a mail with a code is sent, and the user is redirected to a page where they can type the two-factor code. +**Request:** ```http POST /oidc/twofactor HTTP/1.1 Host: name00001.mycozy.cloud @@ -171,201 +506,246 @@ Content-Type: application/x-www-form-urlencoded trusted-device-token=xxx&access-token=yyy&redirect=&confirm= ``` +**Response:** ```http HTTP/1.1 303 See Other Set-Cookie: ... Location: https://name00001-home.mycozy.cloud/ ``` -#### GET /oidc/bitwarden/:context +If no trusted device token, sends a 2FA code by email and shows input form. -This route can be used by a bitwarden client to get a token from the OpenID -Connect Identity Provider, and the fqdn of the associated cozy instance. This -token can then be exchanged for credentials for the cozy instance. +#### POST /oidc/access_token + +Exchanges an OIDC token or delegated code for cozy-stack OAuth tokens. +Requires `allow_oauth_token: true` in configuration. +**Request with OIDC token:** ```http -GET /oidc/bitwarden/examplecontext?redirect_uri=cozypass://login HTTP/1.1 -Host: oauthcallback.mycozy.cloud +POST /oidc/access_token HTTP/1.1 +Host: name00001.mycozy.cloud +Content-Type: application/json + +{ + "client_id": "55eda056e85468fdfe2c8440d4009cbe", + "client_secret": "DttCGIUOTniVNkivR_CsZ_xRoME9gghN", + "scope": "io.cozy.files io.cozy.photos.albums", + "oidc_token": "769fa760-59de-11e9-a167-9bab3784e3e7" +} ``` +**Request with delegated code (flagship):** ```http -HTTP/1.1 303 See Other -Location: https://identity-provider/path/to/authorize?response_type=code&state=9f6873dfce7d&scope=openid+profile+email&client_id=aClientID&nonce=94246498&redirect_uri=https://oauthcallback.mycozy.cloud/oidc/redirect -``` +POST /oidc/access_token HTTP/1.1 +Host: name00001.mycozy.cloud +Content-Type: application/json -[...] +{ + "client_id": "55eda056e85468fdfe2c8440d4009cbe", + "client_secret": "DttCGIUOTniVNkivR_CsZ_xRoME9gghN", + "scope": "*", + "code": "delegated-code-from-cloudery" +} +``` +**Request with ID token:** ```http -HTTP/1.1 303 See Other -Location: cozypass://login?code=xxx&instance=alice.example.com +POST /oidc/access_token HTTP/1.1 +Host: name00001.mycozy.cloud +Content-Type: application/json + +{ + "client_id": "55eda056e85468fdfe2c8440d4009cbe", + "client_secret": "DttCGIUOTniVNkivR_CsZ_xRoME9gghN", + "scope": "io.cozy.files", + "id_token": "eyJhbGciOiJSUzI1NiIs..." +} ``` -#### POST /oidc/bitwarden/:context +**Response:** +```http +HTTP/1.1 200 OK +Content-Type: application/json -This route can be used by a bitwarden client to exchange the token from the -previous route for credentials. +{ + "access_token": "ooch1Yei", + "token_type": "bearer", + "refresh_token": "ui0Ohch8", + "scope": "io.cozy.files io.cozy.photos.albums" +} +``` +**2FA required response:** ```http -POST /oidc/bitwarden/examplecontext HTTP/1.1 -Host: alice.example.com -Content-Type: application/x-www-form-urlencoded -``` +HTTP/1.1 403 Forbidden +Content-Type: application/json +{ + "error": "two factor needed", + "two_factor_token": "123123123123" +} ``` -code=xxx& -password=myHashedPassword& -client_id=mobile& -deviceType=0& -deviceIdentifier=aac2e34a-44db-42ab-a733-5322dd582c3d& -deviceName=android& -clientName=CozyPass& -devicePushToken= + +Then retry with: +```json +{ + "client_id": "...", + "client_secret": "...", + "scope": "...", + "oidc_token": "...", + "two_factor_token": "123123123123", + "two_factor_passcode": "678678" +} ``` +**Flagship certification required response:** ```http -HTTP/1.1 200 OK +HTTP/1.1 202 Accepted Content-Type: application/json -``` -```json { - "client_id": "f05671e159450b44d5c78cebbd0260b5", - "registration_access_token": "J9l-ZhwP[...omitted for brevity...]", - "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkJDMz[...](JWT string)", - "expires_in": 3600, - "token_type": "Bearer", - "refresh_token": "28fb1911ef6db24025ce1bae5aa940e117eb09dfe609b425b69bff73d73c03bf", - "Key": "0.uRcMe+Mc2nmOet4yWx9BwA==|PGQhpYUlTUq/vBEDj1KOHVMlTIH1eecMl0j80+Zu0VRVfFa7X/MWKdVM6OM/NfSZicFEwaLWqpyBlOrBXhR+trkX/dPRnfwJD2B93hnLNGQ=", - "PrivateKey": null + "session_code": "ZmY4ODI3NGMtOTY1Yy0xMWVjLThkMDgtMmI5M2" } ``` +The `session_code` can be used in the OAuth authorize page query string. + #### POST /oidc/:context/logout -This route implements the OpenID Connect Back-Channel Logout. It means that the -SSO can call this endpoint to logout the user. +Back-channel logout endpoint called by the OIDC provider. +**Request:** ```http -POST /oidc/a-context/logout HTTP/1.1 -Host: name00001.mycozy.cloud +POST /oidc/mycontext/logout HTTP/1.1 +Host: oauthcallback.mycozy.cloud Content-Type: application/x-www-form-urlencoded -logout_token=eyJhbGci ... .eyJpc3Mi ... .T3BlbklE ... +logout_token=eyJhbGciOiJSUzI1NiIs... ``` +**Response:** ```http HTTP/1.1 200 OK Cache-Control: no-store ``` -#### POST /oidc/access_token +**Error responses:** +- `400 Bad Request` - Invalid configuration, invalid token, or internal error -This additional route can be used by an OAuth client (like a mobile app) when -delegated authentication via OpenID Connect is enabled. It allows the client to -obtain an `access_token` for requesting the cozy-stack in exchange of a token -valid on the OpenID Connect Identity Provider. +**Logout token requirements:** +- Must be a valid JWT signed with keys from `id_token_jwk_url` +- Must contain `sub` claim (for `allow_custom_instance`) or domain field -```http -POST /oidc/access_token HTTP/1.1 -Host: name00001.mycozy.cloud -Accept: application/json -Content-Type: application/json -``` +#### GET /oidc/bitwarden/:context -```json -{ - "client_id": "55eda056e85468fdfe2c8440d4009cbe", - "client_secret": "DttCGIUOTniVNkivR_CsZ_xRoME9gghN", - "scope": "io.cozy.files io.cozy.photos.albums", - "oidc_token": "769fa760-59de-11e9-a167-9bab3784e3e7" -} +This route can be used by a bitwarden client to get a token from the OpenID +Connect Identity Provider, and the fqdn of the associated cozy instance. This +token can then be exchanged for credentials for the cozy instance. + +**Request:** +```http +GET /oidc/bitwarden/mycontext?redirect_uri=cozypass://login HTTP/1.1 +Host: oauthcallback.mycozy.cloud ``` +**Response:** ```http -HTTP/1.1 200 OK -Content-Type: application/json +HTTP/1.1 303 See Other +Location: https://identity-provider/authorize?response_type=code&state=... ``` -```json -{ - "access_token": "ooch1Yei", - "token_type": "bearer", - "refresh_token": "ui0Ohch8", - "scope": "io.cozy.files io.cozy.photos.albums" -} +After OIDC completes: +```http +HTTP/1.1 303 See Other +Location: cozypass://login?code=xxx&instance=alice.example.com ``` -If `id_token_jwk_url` option is set, the client can send an `id_token` instead -of an `oidc_token` in the payload. +#### POST /oidc/bitwarden/:context -If the flagship makes the request, it also can use a delegated code obtained -from the cloudery, by using `code` instead of `oidc_token`. When using a -delegated code, the session ID (`sid`) is already stored with the code (the -cloudery provides the `id_token` when requesting the delegated code), so the -flagship app does not need to send an `id_token` separately. +Exchanges the Bitwarden code for credentials. -Cozy keeps the session identifier on the OAuth client and, when the user signs -out of the flagship app (which deletes the client), the stack performs a -best-effort call to the OpenID provider's `end_session_endpoint` so the -upstream SSO session is closed too. This complements the back-channel logout -endpoint (`POST /oidc/:context/logout`) that the provider can call to terminate -Cozy sessions. +**Request:** +```http +POST /oidc/bitwarden/mycontext HTTP/1.1 +Host: alice.example.com +Content-Type: application/x-www-form-urlencoded -**Note:** if the OAuth client asks for a `*` scope and has not been certified -as the flagship app, this request will return: +code=xxx&password=myHashedPassword&client_id=mobile&deviceType=0&deviceIdentifier=aac2e34a-44db-42ab-a733-5322dd582c3d&deviceName=android&clientName=CozyPass +``` +**Response:** ```http -HTTP/1.1 202 Accepted +HTTP/1.1 200 OK Content-Type: application/json -``` -```json { - "session_code": "ZmY4ODI3NGMtOTY1Yy0xMWVjLThkMDgtMmI5M2" + "client_id": "f05671e159450b44d5c78cebbd0260b5", + "registration_access_token": "J9l-ZhwP...", + "access_token": "eyJhbGciOiJSUzI1NiIs...", + "expires_in": 3600, + "token_type": "Bearer", + "refresh_token": "28fb1911ef6db24025ce1bae5aa940e117eb09dfe609b425b69bff73d73c03bf", + "Key": "0.uRcMe+Mc2nmOet4yWx9BwA==|...", + "PrivateKey": null } ``` -The `session_code` can be put in the query string while opening the OAuth -authorize page. It will be used to open the session, and let the user type the -6-digits code they have received by mail to confirm that they want to use this -app as the flagship app. +#### GET /oidc/franceconnect -##### Special case of 2FA +Starts the OIDC flow specifically for FranceConnect. See [FranceConnect](#franceconnect) section. -When 2FA is enabled on the instance, the stack will first respond with: +--- +### Admin Routes + +These routes are used by the Cloudery and require admin authentication. + +#### POST /oidc/:context/:provider/code + +Creates a delegated code for the flagship app. The Cloudery calls this after +completing the OIDC dance with the provider. + +**Request:** ```http -HTTP/1.1 403 Forbidden +POST /oidc/mycontext/oidc/code HTTP/1.1 +Host: admin.cozy.example Content-Type: application/json -``` +Authorization: Bearer admin-token -```json { - "error": "two factor needed", - "two_factor_token": "123123123123" + "access_token": "oidc-access-token", + "id_token": "oidc-id-token" } ``` -and the client must ask the user to type its 6-digits code, and then make again -the request: +**Response:** +```http +HTTP/1.1 200 OK +Content-Type: application/json -```json { - "access_token": "ooch1Yei", - "token_type": "bearer", - "refresh_token": "ui0Ohch8", - "scope": "io.cozy.files io.cozy.photos.albums", - "two_factor_token": "123123123123", - "two_factor_passcode": "678678" + "sub": "user-subject-id", + "email": "user@example.com", + "delegated_code": "abc123def456" } ``` +**Processing:** +1. Calls UserInfo endpoint with the access_token +2. Extracts `sid` from id_token (for logout support) +3. Creates delegated code with 3-hour TTL +4. Returns UserInfo claims plus delegated_code + +The `:provider` parameter can be `oidc` or `franceconnect`. + +--- + ## FranceConnect -It is pretty much the same thing as OIDC. It's logical as FranceConnect is an -OIDC provider. But we have made a special case for the login page. The -differences are that the flow is started with `GET /oidc/franceconnect` -(instead of `GET /oidc/start`) and the configuration looks like this: +FranceConnect is a French government OIDC provider. It works similarly to +generic OIDC but has a dedicated configuration section and login flow. + +### Configuration ```yaml authentication: @@ -380,4 +760,41 @@ authentication: userinfo_url: https://identity-provider/path/to/userinfo ``` -The last 3 URL can be omited for production. +The last 3 URLs can be omitted for production (defaults to FranceConnect +production URLs). + +### Differences from Generic OIDC + +| Aspect | Generic OIDC | FranceConnect | +|--------|--------------|---------------| +| Start route | `GET /oidc/start` | `GET /oidc/franceconnect` | +| Login UI | Standard OIDC button | FranceConnect branded button | +| Token request | Basic auth header | Client credentials in body | +| Instance matching | `oidc_id` field | `franceconnect_id` field | +| Nonce validation | Optional | Required | + +### Instance Matching + +For FranceConnect, the instance's `franceconnect_id` field is matched against +the `sub` claim (instead of `oidc_id` for generic OIDC). + +--- + +## Sharing with OIDC + +OIDC can be used during the sharing flow to authenticate users accepting shares. + +### Routes + +#### GET /oidc/sharing + +Starts OIDC authentication for a sharing flow. Used when the recipient needs +to authenticate to accept a share. + +**Query parameters:** +- `sharing_id` - ID of the sharing +- `state` - Sharing state token + +#### GET /oidc/sharing/public + +Similar to above but for public sharing links that require OIDC authentication.