From b7aecc29d74d52266d43ce6883358b8f9763dc9e Mon Sep 17 00:00:00 2001 From: piquel Date: Fri, 13 Nov 2026 23:11:47 +0100 Subject: [PATCH] fix(auth): PKCE is optional for confidential clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every authorization request had to carry an S256 challenge, so a confidential client that sends none, as OpenID certification's Basic and logout plans do, failed at its first authorization. Following RFC 9700 §2.1.1, a confidential client may now leave PKCE out: it authenticates with its secret at the token endpoint. Public clients must still send a challenge, a challenge sent is always enforced, and a code issued without one can't be redeemed with a code_verifier, so a request can't be downgraded. Closes #514 --- apps/auth-service/src/deletion/tests.rs | 2 +- .../src/oidc/authorize/parameters.rs | 68 +++++++++++-------- .../src/oidc/tests/authorization.rs | 58 ++++++++++++++++ apps/auth-service/src/oidc/token.rs | 34 +++++++--- apps/auth-service/src/tests.rs | 2 +- ...591b2c73a8adbaf5bc25db5fe0afa34995bf6.json | 2 +- ...fceb2f261b43d1566b88f638ef89fdf736ad5.json | 2 +- ...bf41a8ac9169e67ddb23346b5c9d285849012.json | 2 +- ...9f58f30a3de8c66102f5d3fd92f3339872cc9.json | 2 +- ...2d0a4fc9ddf1b34b5b1003ed84f08d73156ac.json | 2 +- .../migrations/0048_optional_pkce.sql | 5 ++ crates/database/src/repositories/oidc/mod.rs | 16 ++--- .../database/src/repositories/oidc/tests.rs | 2 +- docs/dev/architecture/oidc.md | 5 +- docs/dev/operations/clients.md | 3 +- docs/dev/security/boundaries.md | 8 ++- docs/dev/security/threat-model.md | 2 +- docs/user/apps/how-sign-in-works.md | 4 +- docs/user/apps/register-an-app.md | 3 +- docs/user/apps/troubleshooting.md | 2 +- docs/user/reference/oidc.md | 11 +++ 21 files changed, 168 insertions(+), 67 deletions(-) create mode 100644 crates/database/migrations/0048_optional_pkce.sql diff --git a/apps/auth-service/src/deletion/tests.rs b/apps/auth-service/src/deletion/tests.rs index 81837f249..c0670c7f8 100644 --- a/apps/auth-service/src/deletion/tests.rs +++ b/apps/auth-service/src/deletion/tests.rs @@ -153,7 +153,7 @@ impl Fixture { user_id: browser.user_id, session_id: browser.session_id, redirect_uri: "https://owned.example/callback".into(), - pkce_challenge: "challenge".into(), + pkce_challenge: Some("challenge".into()), nonce: None, scopes: vec!["openid".into()], audience: client.to_string(), diff --git a/apps/auth-service/src/oidc/authorize/parameters.rs b/apps/auth-service/src/oidc/authorize/parameters.rs index d69fc10e3..66a6c83c2 100644 --- a/apps/auth-service/src/oidc/authorize/parameters.rs +++ b/apps/auth-service/src/oidc/authorize/parameters.rs @@ -10,7 +10,7 @@ use crate::oidc::{ use chrono::{Duration, Utc}; use serde::Deserialize; use serde_json::Value; -use sourceorigin_common::{UserId, protocol::claims::Acr}; +use sourceorigin_common::{UserId, clients::ClientKind, protocol::claims::Acr}; use sourceorigin_repository::{InsertAuthorizationRequest, StoredOidcClient}; use url::Url; @@ -33,10 +33,11 @@ pub(in crate::oidc) struct AuthorizationParameters { pub(super) state: String, /// Client value echoed in the ID token. nonce: Option, - /// PKCE challenge the code exchange must answer. - code_challenge: String, - /// Must be `S256`. - code_challenge_method: String, + /// PKCE challenge the code exchange must answer. Only a confidential client + /// may leave it out. + code_challenge: Option, + /// Must be `S256` when `code_challenge` is sent. + code_challenge_method: Option, /// Resource the access token is for; defaults to the client's first audience. audience: Option, /// Space-separated [`Prompt`] values. @@ -81,9 +82,8 @@ impl AuthorizationParameters { self.supported()?; let hinted_user_id = self.hinted_user_id(app, client)?; self.response_type()?; - self.code_challenge_method()?; + let pkce_challenge = self.pkce(client)?; self.state()?; - self.code_challenge()?; let scopes = self.scopes(client)?; let audience = self.audience(client)?; self.nonce()?; @@ -104,7 +104,7 @@ impl AuthorizationParameters { client_id: client.id.clone(), redirect_uri: self.redirect_uri.clone(), client_state: self.state.clone(), - pkce_challenge: self.code_challenge.clone(), + pkce_challenge, nonce: self.nonce.clone(), scopes: scopes.into_stored(), audience, @@ -194,24 +194,6 @@ impl AuthorizationParameters { } } - /// Requires `code_challenge_method=S256`. - fn code_challenge_method(&self) -> Result<(), OAuthError> { - match self - .code_challenge_method - .parse::() - .map_err(|error| { - OAuthCode::InvalidRequest.reject_with( - format!( - "code_challenge_method must be {}", - CodeChallengeMethod::S256 - ), - error, - ) - })? { - CodeChallengeMethod::S256 => Ok(()), - } - } - /// Bounds `state`, which comes back with the result. fn state(&self) -> Result<(), OAuthError> { if self.state.len() > MAX_STATE_BYTES { @@ -221,13 +203,39 @@ impl AuthorizationParameters { Ok(()) } - /// Requires the PKCE challenge to be a SHA-256 digest. - fn code_challenge(&self) -> Result<(), OAuthError> { - if !sourceorigin_crypto::Pkce::valid_challenge(&self.code_challenge) { + /// The PKCE challenge the code exchange must answer (RFC 7636): an `S256` + /// SHA-256 digest. Public clients must send one. A confidential client may + /// leave PKCE out, as RFC 9700 §2.1.1 allows, since it authenticates at the + /// token endpoint; a challenge it sends is enforced all the same. + fn pkce(&self, client: &StoredOidcClient) -> Result, OAuthError> { + let Some(challenge) = &self.code_challenge else { + return match (&self.code_challenge_method, client.kind) { + (Some(_), _) => Err(OAuthCode::InvalidRequest + .reject("code_challenge_method was sent without a code_challenge")), + (None, ClientKind::Confidential) => Ok(None), + (None, ClientKind::Public) => Err(OAuthCode::InvalidRequest.reject(format!( + "public clients must send a code_challenge, with code_challenge_method={}", + CodeChallengeMethod::S256 + ))), + }; + }; + let method = self.code_challenge_method.as_deref().unwrap_or_default(); + match method.parse::().map_err(|error| { + OAuthCode::InvalidRequest.reject_with( + format!( + "code_challenge_method must be {}", + CodeChallengeMethod::S256 + ), + error, + ) + })? { + CodeChallengeMethod::S256 => {} + } + if !sourceorigin_crypto::Pkce::valid_challenge(challenge) { return Err(OAuthCode::InvalidRequest .reject("code_challenge must be a base64url SHA-256 digest, 43 characters long")); } - Ok(()) + Ok(Some(challenge.clone())) } /// Parses the scopes, which `client` must be allowed to request. diff --git a/apps/auth-service/src/oidc/tests/authorization.rs b/apps/auth-service/src/oidc/tests/authorization.rs index 0315a5068..aff7a4562 100644 --- a/apps/auth-service/src/oidc/tests/authorization.rs +++ b/apps/auth-service/src/oidc/tests/authorization.rs @@ -273,3 +273,61 @@ async fn a_consent_whose_session_ended_while_waiting_is_not_saved() { let consents = database.oidc().list_consents(flow.user_id).await; assert!(consents.expect("consents").is_empty(), "not saved"); } + +/// PKCE is optional only for a confidential client, which authenticates at the +/// token endpoint (RFC 9700 §2.1.1): a public client's request without it is +/// refused, a challenge a confidential client sends is still enforced, and a +/// verifier for a code issued without a challenge is refused as a downgrade. +#[tokio::test] +#[ignore = "requires TEST_DATABASE_URL"] +async fn pkce_is_optional_only_for_confidential_clients() { + /// The scenario's authorization request without PKCE, followed to its code. + async fn without_pkce(flow: &Flow) -> Url { + let mut url = flow.authorization_url(Scope::OpenId.as_str(), None, &[]); + let kept: Vec<_> = url + .query_pairs() + .into_owned() + .filter(|(name, _)| !name.starts_with("code_challenge")) + .collect(); + url.query_pairs_mut().clear().extend_pairs(kept); + let uri = format!("{}?{}", url.path(), url.query().expect("query")); + flow.consent(flow.request(Method::GET, &uri, &[], None).await) + .await + } + /// Exchanges `code` with the scenario's credentials and no verifier. + async fn exchange_without_verifier(flow: &Flow, code: &str) -> Response { + flow.request( + Method::POST, + "/oauth/token", + &[ + ("grant_type", GrantType::AuthorizationCode.as_str()), + ("redirect_uri", flow.client.redirect_uris[0].as_str()), + ("code", code), + ], + flow.basic.as_deref(), + ) + .await + } + + let public = Flow::new(false, true).await; + assert_eq!( + parameter(&without_pkce(&public).await, "error"), + OAuthCode::InvalidRequest.as_str() + ); + + let confidential = Flow::new(true, true).await; + let downgraded = parameter(&without_pkce(&confidential).await, "code"); + let basic = confidential.basic.as_deref(); + Flow::assert_invalid_grant( + confidential + .exchange(&downgraded, Flow::verifier(), basic) + .await, + ) + .await; + let code = parameter(&without_pkce(&confidential).await, "code"); + confidential + .id_token(exchange_without_verifier(&confidential, &code).await) + .await; + let challenged = confidential.code(Scope::OpenId.as_str()).await; + Flow::assert_invalid_grant(exchange_without_verifier(&confidential, &challenged).await).await; +} diff --git a/apps/auth-service/src/oidc/token.rs b/apps/auth-service/src/oidc/token.rs index 66c8e6920..e3a981aa0 100644 --- a/apps/auth-service/src/oidc/token.rs +++ b/apps/auth-service/src/oidc/token.rs @@ -55,7 +55,7 @@ pub(super) struct TokenForm { impl TokenForm { /// Whether this form redeems the locked `code` for the authenticated /// `client_id`: the code was issued to it, for this `redirect_uri`, and this - /// `code_verifier` matches its PKCE challenge. Answers why it does not, with + /// `code_verifier` matches its PKCE challenge, or neither was sent. Answers why it does not, with /// the rejection. fn redeems( &self, @@ -75,17 +75,29 @@ impl TokenForm { .reject("redirect_uri must match the one sent with the authorization request"), )); } - sourceorigin_crypto::Pkce::verify( - self.code_verifier.as_deref().unwrap_or_default(), - &code.pkce_challenge, - ) - .map_err(|error| { - ( + match (&code.pkce_challenge, self.code_verifier.as_deref()) { + (Some(challenge), verifier) => { + sourceorigin_crypto::Pkce::verify(verifier.unwrap_or_default(), challenge).map_err( + |error| { + ( + CodeRedemptionFailure::InvalidVerifier, + OAuthCode::InvalidGrant + .reject_with(format!("code_verifier is invalid: {error}"), error), + ) + }, + ) + } + // A confidential client sent no challenge, so it sends no verifier. + (None, None) => Ok(()), + // RFC 9700 §2.1.1: a verifier for a code issued without a challenge + // may be an attacker's downgrade of a request that had one. + (None, Some(_)) => Err(( CodeRedemptionFailure::InvalidVerifier, - OAuthCode::InvalidGrant - .reject_with(format!("code_verifier is invalid: {error}"), error), - ) - }) + OAuthCode::InvalidGrant.reject( + "code_verifier was sent, but the authorization request had no code_challenge", + ), + )), + } } /// Permits refresh scope narrowing while rejecting additions to the original grant. diff --git a/apps/auth-service/src/tests.rs b/apps/auth-service/src/tests.rs index c7f53388d..4a982fd87 100644 --- a/apps/auth-service/src/tests.rs +++ b/apps/auth-service/src/tests.rs @@ -749,7 +749,7 @@ pub(crate) async fn refresh_grant( user_id, session_id, redirect_uri: "https://client.example/callback".into(), - pkce_challenge: "challenge".into(), + pkce_challenge: Some("challenge".into()), nonce: None, scopes: vec!["openid".into()], audience: client.to_string(), diff --git a/crates/database/.sqlx/query-42ac9c52f8c14b06434dccc3564591b2c73a8adbaf5bc25db5fe0afa34995bf6.json b/crates/database/.sqlx/query-42ac9c52f8c14b06434dccc3564591b2c73a8adbaf5bc25db5fe0afa34995bf6.json index d66b81d94..7162075cc 100644 --- a/crates/database/.sqlx/query-42ac9c52f8c14b06434dccc3564591b2c73a8adbaf5bc25db5fe0afa34995bf6.json +++ b/crates/database/.sqlx/query-42ac9c52f8c14b06434dccc3564591b2c73a8adbaf5bc25db5fe0afa34995bf6.json @@ -93,7 +93,7 @@ false, false, true, - false, + true, false, false, false, diff --git a/crates/database/.sqlx/query-a35642846dde2778d46c8f41779fceb2f261b43d1566b88f638ef89fdf736ad5.json b/crates/database/.sqlx/query-a35642846dde2778d46c8f41779fceb2f261b43d1566b88f638ef89fdf736ad5.json index 59f51fc82..5caaf535c 100644 --- a/crates/database/.sqlx/query-a35642846dde2778d46c8f41779fceb2f261b43d1566b88f638ef89fdf736ad5.json +++ b/crates/database/.sqlx/query-a35642846dde2778d46c8f41779fceb2f261b43d1566b88f638ef89fdf736ad5.json @@ -66,7 +66,7 @@ false, false, false, - false, + true, true, false, false, diff --git a/crates/database/.sqlx/query-b55c00b8d90ca13fe21b71bd171bf41a8ac9169e67ddb23346b5c9d285849012.json b/crates/database/.sqlx/query-b55c00b8d90ca13fe21b71bd171bf41a8ac9169e67ddb23346b5c9d285849012.json index 1f75771a1..42f5babf3 100644 --- a/crates/database/.sqlx/query-b55c00b8d90ca13fe21b71bd171bf41a8ac9169e67ddb23346b5c9d285849012.json +++ b/crates/database/.sqlx/query-b55c00b8d90ca13fe21b71bd171bf41a8ac9169e67ddb23346b5c9d285849012.json @@ -93,7 +93,7 @@ false, false, true, - false, + true, false, false, false, diff --git a/crates/database/.sqlx/query-c2caf3e062bcf46312ba760ccaf9f58f30a3de8c66102f5d3fd92f3339872cc9.json b/crates/database/.sqlx/query-c2caf3e062bcf46312ba760ccaf9f58f30a3de8c66102f5d3fd92f3339872cc9.json index 345c9c7ad..38ade4010 100644 --- a/crates/database/.sqlx/query-c2caf3e062bcf46312ba760ccaf9f58f30a3de8c66102f5d3fd92f3339872cc9.json +++ b/crates/database/.sqlx/query-c2caf3e062bcf46312ba760ccaf9f58f30a3de8c66102f5d3fd92f3339872cc9.json @@ -66,7 +66,7 @@ false, false, false, - false, + true, true, false, false, diff --git a/crates/database/.sqlx/query-f2738a32dac0d1b49340ecbc82b2d0a4fc9ddf1b34b5b1003ed84f08d73156ac.json b/crates/database/.sqlx/query-f2738a32dac0d1b49340ecbc82b2d0a4fc9ddf1b34b5b1003ed84f08d73156ac.json index 0b90d0b24..bfb998e00 100644 --- a/crates/database/.sqlx/query-f2738a32dac0d1b49340ecbc82b2d0a4fc9ddf1b34b5b1003ed84f08d73156ac.json +++ b/crates/database/.sqlx/query-f2738a32dac0d1b49340ecbc82b2d0a4fc9ddf1b34b5b1003ed84f08d73156ac.json @@ -97,7 +97,7 @@ false, false, true, - false, + true, false, false, false, diff --git a/crates/database/migrations/0048_optional_pkce.sql b/crates/database/migrations/0048_optional_pkce.sql new file mode 100644 index 000000000..e83b6e8ef --- /dev/null +++ b/crates/database/migrations/0048_optional_pkce.sql @@ -0,0 +1,5 @@ +-- A confidential client may leave PKCE out of an authorization request, as +-- RFC 9700 allows: it authenticates at the token endpoint. pkce_challenge is +-- NULL for such a request and the code it issues. +ALTER TABLE authorization_requests ALTER COLUMN pkce_challenge DROP NOT NULL; +ALTER TABLE authorization_codes ALTER COLUMN pkce_challenge DROP NOT NULL; diff --git a/crates/database/src/repositories/oidc/mod.rs b/crates/database/src/repositories/oidc/mod.rs index a08554f55..10b7be96a 100644 --- a/crates/database/src/repositories/oidc/mod.rs +++ b/crates/database/src/repositories/oidc/mod.rs @@ -58,8 +58,8 @@ pub struct InsertAuthorizationRequest { pub client_state: String, /// The client's `nonce` parameter, echoed in the ID token. pub nonce: Option, - /// PKCE `S256` code challenge. - pub pkce_challenge: String, + /// PKCE `S256` code challenge; `None` when a confidential client sent none. + pub pkce_challenge: Option, /// Requested scopes. pub scopes: Vec, /// Requested token audience. @@ -121,8 +121,8 @@ pub struct StoredAuthorizationRequest { pub client_state: String, /// The client's `nonce` parameter. pub nonce: Option, - /// PKCE `S256` code challenge. - pub pkce_challenge: String, + /// PKCE `S256` code challenge; `None` when a confidential client sent none. + pub pkce_challenge: Option, /// Requested scopes. pub scopes: Vec, /// Requested token audience. @@ -158,8 +158,8 @@ pub struct InsertAuthorizationCode { pub session_id: SessionId, /// Redirect URI the code was delivered to. pub redirect_uri: String, - /// PKCE `S256` code challenge. - pub pkce_challenge: String, + /// PKCE `S256` code challenge; `None` when a confidential client sent none. + pub pkce_challenge: Option, /// The client's `nonce` parameter. pub nonce: Option, /// Granted scopes. @@ -187,8 +187,8 @@ pub struct ConsumedAuthorizationCode { pub session_id: SessionId, /// Redirect URI the code was delivered to. pub redirect_uri: String, - /// PKCE `S256` code challenge. - pub pkce_challenge: String, + /// PKCE `S256` code challenge; `None` when a confidential client sent none. + pub pkce_challenge: Option, /// The client's `nonce` parameter. pub nonce: Option, /// Granted scopes. diff --git a/crates/database/src/repositories/oidc/tests.rs b/crates/database/src/repositories/oidc/tests.rs index 42cd1d7f9..688e418df 100644 --- a/crates/database/src/repositories/oidc/tests.rs +++ b/crates/database/src/repositories/oidc/tests.rs @@ -132,7 +132,7 @@ async fn pushed_requests_are_claimed_only_before_they_expire() { redirect_uri: format!("{origin}/callback"), client_state: "state".into(), nonce: None, - pkce_challenge: "challenge".into(), + pkce_challenge: Some("challenge".into()), scopes: vec![Scope::OpenId.as_str().into()], audience: client.id.to_string(), interaction: Interaction::AsNeeded, diff --git a/docs/dev/architecture/oidc.md b/docs/dev/architecture/oidc.md index cb87889f9..8451bf191 100644 --- a/docs/dev/architecture/oidc.md +++ b/docs/dev/architecture/oidc.md @@ -48,7 +48,7 @@ URI, always with `state` and `iss` (`ClientRedirect`). pushed requests, one named step per check: no request object and no `response_mode` but `query`, an `id_token_hint` that auth signed for this client, which names the only account that may answer, `response_type=code`, an -S256 PKCE challenge, `state`, scopes and audience the client allows, `nonce`, +S256 PKCE challenge, which only a confidential client may leave out, `state`, scopes and audience the client allows, `nonce`, `max_age` and `prompt`. It returns a `ValidatedAuthorization`, whose `store` saves the request and returns its token. The request is stored in `authorization_requests` for ten minutes, with its first known `acr_values` @@ -138,7 +138,8 @@ period. 1. It locks the code's account, then the code (`lock_code`), and checks that the form redeems it: the same client, the same `redirect_uri`, and a - `code_verifier` matching its PKCE challenge. A failed check expires the code + `code_verifier` matching its PKCE challenge, or none for a code issued + without one. A failed check expires the code and records `CodeRedemptionFailed`, so a code is tried once. The account is locked whatever its status, and a disabled one isn't refused here. Its code fails as any other would: another client presenting it gets the usual diff --git a/docs/dev/operations/clients.md b/docs/dev/operations/clients.md index 57ce8e8fa..b08d03375 100644 --- a/docs/dev/operations/clients.md +++ b/docs/dev/operations/clients.md @@ -146,7 +146,8 @@ sign-in**. See [passkey sign-in on your own pages](../../user/apps/passkey-sign- - **Kind.** Clients are public by default, and authenticate with PKCE alone (`none`). Confidential clients (`--confidential`) use `client_secret_basic` - with a secret auth generates. Both require S256 PKCE. + with a secret auth generates. Public clients must send S256 PKCE; confidential + clients may leave it out, and a challenge they send is enforced. - **Redirect URIs** must match exactly. They use HTTPS, or HTTP on a loopback IP for native apps, without credentials or a fragment. Write them as URL parsing normalizes them: `https://app.example/`, not `https://APP.example`. The diff --git a/docs/dev/security/boundaries.md b/docs/dev/security/boundaries.md index a99a44c3b..e1f057081 100644 --- a/docs/dev/security/boundaries.md +++ b/docs/dev/security/boundaries.md @@ -168,9 +168,11 @@ back-channel logout tokens. **What is checked** (`apps/auth-service/src/oidc`): -- Redirect URIs must match a registered one exactly, and every client uses S256 - PKCE (`oidc/authorize/parameters.rs`). A confidential client also - authenticates with its secret. +- Redirect URIs must match a registered one exactly, and a public client must + use S256 PKCE (`oidc/authorize/parameters.rs`). A confidential client + authenticates with its secret, so PKCE is optional for it (RFC 9700 §2.1.1); + a challenge it sends is enforced, and a `code_verifier` for a code issued + without one is refused, so a request can't be downgraded. - A code is tried once, and every step re-reads the client's registration ([codes, tokens and consent](../../user/reference/oidc.md#codes-tokens-and-consent)). - Only the claims a client asked for, and the user consented to, are released. diff --git a/docs/dev/security/threat-model.md b/docs/dev/security/threat-model.md index 53a67f05f..95f17ba0b 100644 --- a/docs/dev/security/threat-model.md +++ b/docs/dev/security/threat-model.md @@ -59,7 +59,7 @@ own. It builds on the [security boundaries](boundaries.md); the | Threat | Mitigation | | --- | --- | -| A code or token used by another app, or sent elsewhere | Exact redirect URIs, S256 PKCE for every client, codes tried once, and tokens bound to their audience ([OIDC](../../user/reference/oidc.md#codes-tokens-and-consent)). | +| A code or token used by another app, or sent elsewhere | Exact redirect URIs, S256 PKCE for every public client and a secret for every confidential one, codes tried once, and tokens bound to their audience ([OIDC](../../user/reference/oidc.md#codes-tokens-and-consent)). | | An app impersonating another, or SourceOrigin | Console apps are never trusted, so the consent page shows unless the user already consented to the scopes asked, naming the host it returns to and labelling the app unverified with its owner's handle. `prompt=consent` forces it. Their names may not contain a well-known brand ([OIDC clients](../operations/clients.md)). | | An app keeping access | The owner, an administrator or an operator removes an app's access, which ends its refresh grants and outstanding codes. A public client's refresh token rotates, and replaying a spent one revokes the grant, unless it repeats the newest rotation within 10 seconds, which answers with the same new token. | | A logout URI aimed at the internal network | Deliveries go only over HTTPS to public addresses, and never follow a redirect ([outbound HTTP](../architecture/delivery-queue.md#outbound-http)). | diff --git a/docs/user/apps/how-sign-in-works.md b/docs/user/apps/how-sign-in-works.md index 3e85c75f9..420510caa 100644 --- a/docs/user/apps/how-sign-in-works.md +++ b/docs/user/apps/how-sign-in-works.md @@ -26,7 +26,9 @@ of two kinds, and its kind never changes: - **Browser or native app.** A single-page, mobile or desktop app can't hide a secret, so it proves each exchange with PKCE alone. -Both kinds use PKCE (S256). +A browser or native app must send PKCE (S256) with every sign-in. A web app with +a backend should too, and the SDKs always do, but it may leave PKCE out: its +secret already proves each exchange. ## Redirect URIs diff --git a/docs/user/apps/register-an-app.md b/docs/user/apps/register-an-app.md index 99354595a..35e1ac6f3 100644 --- a/docs/user/apps/register-an-app.md +++ b/docs/user/apps/register-an-app.md @@ -28,7 +28,8 @@ and never appear in the console: see [OIDC clients](../../dev/operations/clients - **No, it runs in the browser or on a device**: your app signs users in without a secret. - You can't change this later. Both kinds sign in with PKCE. + You can't change this later. An app without a secret must sign in with + PKCE; the SDKs use it for both kinds. 4. Fill in **Redirect URIs**, one per line. 5. Leave the other settings as they are for now: [app settings](app-settings.md) explains each one. An app with a backend starts with `offline_access` under diff --git a/docs/user/apps/troubleshooting.md b/docs/user/apps/troubleshooting.md index 241cb5073..3ba5ffa65 100644 --- a/docs/user/apps/troubleshooting.md +++ b/docs/user/apps/troubleshooting.md @@ -35,7 +35,7 @@ that says what went wrong: search this page for either. | `error` | Common causes | What to do | | --- | --- | --- | -| `invalid_request` | The request lacks S256 PKCE, sends `state` or `nonce` longer than 512 bytes, or an `id_token_hint` issued to another app. Or the app requires pushed requests and this one came through the browser. | Fix what the description names. The SDKs send valid requests, and push them whenever auth offers it. | +| `invalid_request` | A browser or native app's request lacks S256 PKCE, the request sends `state` or `nonce` longer than 512 bytes, or an `id_token_hint` issued to another app. Or the app requires pushed requests and this one came through the browser. | Fix what the description names. The SDKs send valid requests, and push them whenever auth offers it. | | `invalid_scope` | The request asks for a scope the app may not request, often `offline_access`, which the SDKs' login routes always request. | Check the scope under **Scopes** on the app's **Settings** tab ([app settings](app-settings.md#settings)). | | `invalid_target` | `audience` names something other than the app. | Leave `audience` out: an app registered in the console gets access tokens for itself only. | | `access_denied` | The user selected **Cancel** on the consent screen. | Let the user try again. | diff --git a/docs/user/reference/oidc.md b/docs/user/reference/oidc.md index 33bc55cec..d8f54b44d 100644 --- a/docs/user/reference/oidc.md +++ b/docs/user/reference/oidc.md @@ -25,6 +25,17 @@ come back as JSON. Discovery advertises `pushed_authorization_request_endpoint`. Client-hosted request objects (`request` and `request_uri` from RFC 9101) are not supported. +## PKCE + +A public client must send an S256 PKCE challenge (`code_challenge` and +`code_challenge_method=S256`) with every authorization request. A confidential +client may leave PKCE out, as RFC 9700 §2.1.1 allows: it authenticates with its +secret when it redeems the code, and OpenID Connect's `nonce` binds the ID token +to its request. A challenge a confidential client sends is enforced like any +other, and a code issued without one can't be redeemed with a `code_verifier`, +so an attacker can't strip PKCE from a request. Discovery advertises `S256` in +`code_challenge_methods_supported`. The SDKs always send PKCE. + ## Codes, tokens and consent The endpoints implement code exchange, RS256 ID/access tokens, userinfo, opt-in