From 4fdb6c4bc6df1a0ab1e7df4a15d382ad6142e25f Mon Sep 17 00:00:00 2001 From: Sven Schultze Date: Thu, 11 Dec 2025 14:33:56 +0100 Subject: [PATCH 1/4] implement PAR --- PKCE_PAR.md | 131 ++++++++++++ .../community/genericoauth2/ConfigUtils.java | 15 ++ .../genericoauth2/GenericOAuth2Plugin.java | 140 +++++++------ .../genericoauth2/OAuth2Options.java | 18 ++ .../genericoauth2/ParRequestAsyncTask.java | 192 ++++++++++++++++++ .../genericoauth2/ParRequestResult.java | 33 +++ ios/Plugin/GenericOAuth2Plugin.swift | 139 +++++++++++-- src/definitions.ts | 6 + src/web-utils.ts | 113 +++++++++++ src/web.ts | 8 + 10 files changed, 718 insertions(+), 77 deletions(-) create mode 100644 PKCE_PAR.md create mode 100644 android/src/main/java/com/getcapacitor/community/genericoauth2/ParRequestAsyncTask.java create mode 100644 android/src/main/java/com/getcapacitor/community/genericoauth2/ParRequestResult.java diff --git a/PKCE_PAR.md b/PKCE_PAR.md new file mode 100644 index 00000000..28f727bb --- /dev/null +++ b/PKCE_PAR.md @@ -0,0 +1,131 @@ +# PAR + PKCE Behavior Summary + +This fork extends the Generic OAuth2 Capacitor plugin to support **Pushed Authorization Requests (PAR)** with **PKCE** on all platforms (Web, Android, iOS) while keeping the existing API backward compatible. + +## New configuration + +- `OAuth2AuthenticateBaseOptions.parEndpoint?: string` + - URL of the provider's PAR endpoint (`pushed_authorization_request_endpoint`). + - Can be set globally, or per platform using `web.parEndpoint`, `android.parEndpoint`, or `ios.parEndpoint`. + +Behavior: + +- If `parEndpoint` is **unset** for a platform: + - Existing behavior is preserved (no PAR). + - PKCE, authorization, and token flows work as before. +- If `parEndpoint` is **set** for a platform: + - The plugin performs a PAR call before the authorization redirect and uses the returned `request_uri` when starting the authorization flow. + +## Single PKCE verifier per auth attempt + +Across all platforms the plugin now guarantees that: + +- When `pkceEnabled` is `true`, a single **PKCE code verifier** is generated for the auth attempt. +- The **code challenge** used in the PAR request is always derived from this same verifier. +- The same verifier is then used at the **token exchange** step. + +Platform details: + +- **Web** + - `WebUtils.buildWebOptions`: + - Generates `pkceCodeVerifier` once (or reuses it from `sessionStorage`). + - Computes `pkceCodeChallenge` and `pkceCodeChallengeMethod`. + - `WebUtils.performPar`: + - Sends `client_id`, `response_type`, `redirect_uri`, `scope`, `state`, and PKCE (`code_challenge`, `code_challenge_method`) plus `additionalParameters` to `parEndpoint` via `POST` (`application/x-www-form-urlencoded`). + - Reads `request_uri` from the JSON response and stores it. + - `WebUtils.getAuthorizationUrl`: + - If `request_uri` is present, builds an authorization URL with `client_id` and `request_uri` instead of repeating all parameters. + - `getTokenEndpointData`: + - Uses the same `pkceCodeVerifier` as the `code_verifier` in the token request. + +- **Android (AppAuth)** + - `OAuth2Options`: + - Holds `parEndpoint`, `parRequestUri`, `pkceEnabled`, and `pkceCodeVerifier` (generated once per auth attempt). + - `ParRequestAsyncTask`: + - Sends `client_id`, `response_type`, `redirect_uri`, `scope`, `state`, PKCE (`code_challenge` derived from `pkceCodeVerifier` with S256, or plain as fallback), and `additionalParameters` to `parEndpoint` via `POST` (`application/x-www-form-urlencoded`). + - Stores the returned `request_uri` on `OAuth2Options`. + - `GenericOAuth2Plugin.startAuthorization`: + - Builds the usual `AuthorizationRequest.Builder` (state, scope, etc). + - Always passes the same `pkceCodeVerifier` to `setCodeVerifier` when PKCE is enabled. + - Includes `request_uri` in the additional parameters when PAR is used. + +- **iOS (OAuthSwift)** + - `GenericOAuth2Plugin`: + - Generates `requestState` and, when `pkceEnabled`, generates a single `pkceCodeVerifier` and `pkceCodeChallenge` per auth attempt. + - If `parEndpoint` is configured: + - Builds a PAR request with `client_id`, `response_type`, `redirect_uri`, `scope`, `state`, PKCE (`code_challenge`, `code_challenge_method=S256` when enabled), plus `additionalParameters`. + - Sends `POST` (`application/x-www-form-urlencoded`) to `parEndpoint` and extracts `request_uri` from the JSON response. + - Calls `oauthSwift.authorize`: + - Adds `request_uri` to the `parameters` dictionary when PAR is used. + - Passes the same PKCE `codeChallenge`/`codeVerifier` pair to `authorize`, which is reused by OAuthSwift for the token request. + +## Error handling + +If PAR is enabled and fails, the plugin fails fast before opening a browser / external UI: + +- **Web** + - `WebUtils.performPar` rejects with an `Error` whose message starts with `PAR_FAILED:`: + - `PAR_FAILED: missing request_uri in response` + - `PAR_FAILED: invalid JSON response` + - `PAR_FAILED: HTTP [error - error_description]` + - `PAR_FAILED: network error` + +- **Android** + - `ParRequestAsyncTask` rejects the call with: + - Error code: `ERR_PAR_FAILED` + - Message: `PAR_FAILED: ...` (HTTP status, JSON `error` / `error_description`, or network/format issues). + +- **iOS** + - PAR failures reject the CAP plugin call with: + - Error code: `ERR_PAR_FAILED` + - Message: `PAR_FAILED: ...` (HTTP status, JSON parsing issues, or network error). + +Logging: + +- The existing `logsEnabled` flag continues to control logging behavior. +- When `logsEnabled` is `true`, PAR requests and responses are logged (without printing secrets like client secret, which is not used in this flow). + +## Backward compatibility + +- When `parEndpoint` is **not configured**: + - Web, Android, and iOS behave exactly as in the upstream plugin: + - No PAR call is made. + - PKCE (when enabled) works unchanged. +- When `parEndpoint` **is configured**: + - The only behavioral difference is: + - The plugin performs a PAR call before redirecting to the authorization endpoint. + - The authorization redirect uses the resulting `request_uri` alongside the existing PKCE and state handling. + +## Example configuration (Authelia + PAR + PKCE) + +```ts +const options: OAuth2AuthenticateOptions = { + appId: 'my-client-id', + authorizationBaseUrl: 'https://auth.example.com/oauth2/authorize', + accessTokenEndpoint: 'https://auth.example.com/oauth2/token', + parEndpoint: 'https://auth.example.com/oauth2/par', + redirectUrl: 'myapp://callback', + responseType: 'code', + scope: 'openid profile email offline_access', + pkceEnabled: true, + logsEnabled: true, + + // Optional per-platform overrides + web: { + parEndpoint: 'https://auth.example.com/oauth2/par', + }, + android: { + parEndpoint: 'https://auth.example.com/oauth2/par', + }, + ios: { + parEndpoint: 'https://auth.example.com/oauth2/par', + }, + + additionalParameters: { + // any provider-specific params, e.g. audience + }, +}; +``` + +This setup will use PAR + PKCE correctly with providers like Authelia that require both PAR and PKCE, while still allowing non-PAR providers to work by simply omitting `parEndpoint`. + diff --git a/android/src/main/java/com/getcapacitor/community/genericoauth2/ConfigUtils.java b/android/src/main/java/com/getcapacitor/community/genericoauth2/ConfigUtils.java index 8ac716d0..c3b44da4 100644 --- a/android/src/main/java/com/getcapacitor/community/genericoauth2/ConfigUtils.java +++ b/android/src/main/java/com/getcapacitor/community/genericoauth2/ConfigUtils.java @@ -1,6 +1,10 @@ package com.getcapacitor.community.genericoauth2; import com.getcapacitor.JSObject; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; import java.util.HashMap; import java.util.Iterator; import java.util.Map; @@ -185,6 +189,17 @@ public static String getRandomString(int len) { return new String(c); } + public static String derivePkceCodeChallenge(String codeVerifier) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } catch (NoSuchAlgorithmException e) { + // Fallback to plain if SHA-256 is not available + return codeVerifier; + } + } + public static String trimToNull(String value) { if (value != null && value.trim().length() == 0) { return null; diff --git a/android/src/main/java/com/getcapacitor/community/genericoauth2/GenericOAuth2Plugin.java b/android/src/main/java/com/getcapacitor/community/genericoauth2/GenericOAuth2Plugin.java index eb2c9fd9..82c97f97 100644 --- a/android/src/main/java/com/getcapacitor/community/genericoauth2/GenericOAuth2Plugin.java +++ b/android/src/main/java/com/getcapacitor/community/genericoauth2/GenericOAuth2Plugin.java @@ -40,6 +40,7 @@ public class GenericOAuth2Plugin extends Plugin { private static final String PARAM_STATE = "state"; private static final String PARAM_ACCESS_TOKEN_ENDPOINT = "accessTokenEndpoint"; + private static final String PARAM_PAR_ENDPOINT = "parEndpoint"; private static final String PARAM_PKCE_ENABLED = "pkceEnabled"; private static final String PARAM_RESOURCE_URL = "resourceUrl"; private static final String PARAM_ADDITIONAL_RESOURCE_HEADERS = "additionalResourceHeaders"; @@ -213,75 +214,97 @@ public void onError(Exception error) { return; } - // ### Configure - - Uri authorizationUri = Uri.parse(oauth2Options.getAuthorizationBaseUrl()); - Uri accessTokenUri; - if (oauth2Options.getAccessTokenEndpoint() != null) { - accessTokenUri = Uri.parse(oauth2Options.getAccessTokenEndpoint()); + if (oauth2Options.getParEndpoint() != null) { + AsyncTask asyncTask = new ParRequestAsyncTask(call, oauth2Options, getLogTag(), this); + asyncTask.execute(); } else { - // appAuth does not allow to be the accessTokenUri empty although it is not used unit performTokenRequest - accessTokenUri = authorizationUri; + startAuthorization(call); } + } + } - AuthorizationServiceConfiguration config = new AuthorizationServiceConfiguration(authorizationUri, accessTokenUri); + void startAuthorization(final PluginCall call) { + // ### Configure - if (this.authState == null) { - this.authState = new AuthState(config); - } + Uri authorizationUri = Uri.parse(oauth2Options.getAuthorizationBaseUrl()); + Uri accessTokenUri; + if (oauth2Options.getAccessTokenEndpoint() != null) { + accessTokenUri = Uri.parse(oauth2Options.getAccessTokenEndpoint()); + } else { + // appAuth does not allow to be the accessTokenUri empty although it is not used until performTokenRequest + accessTokenUri = authorizationUri; + } - AuthorizationRequest.Builder builder = new AuthorizationRequest.Builder( - config, - oauth2Options.getAppId(), - oauth2Options.getResponseType(), - Uri.parse(oauth2Options.getRedirectUrl()) - ); + AuthorizationServiceConfiguration config = new AuthorizationServiceConfiguration(authorizationUri, accessTokenUri); - // app auth always uses a state - if (oauth2Options.getState() != null) { - builder.setState(oauth2Options.getState()); - } - builder.setScope(oauth2Options.getScope()); - if (oauth2Options.isPkceEnabled()) { - builder.setCodeVerifier(oauth2Options.getPkceCodeVerifier()); - } else { - builder.setCodeVerifier(null); - } - if (oauth2Options.getPrompt() != null) { - builder.setPrompt(oauth2Options.getPrompt()); - } - if (oauth2Options.getLoginHint() != null) { - builder.setLoginHint(oauth2Options.getLoginHint()); - } - if (oauth2Options.getResponseMode() != null) { - builder.setResponseMode(oauth2Options.getResponseMode()); - } - if (oauth2Options.getDisplay() != null) { - builder.setDisplay(oauth2Options.getDisplay()); - } + if (this.authState == null) { + this.authState = new AuthState(config); + } - if (oauth2Options.getAdditionalParameters() != null) { - try { - builder.setAdditionalParameters(oauth2Options.getAdditionalParameters()); - } catch (IllegalArgumentException e) { - // ignore all additional parameter on error - Log.e(getLogTag(), "Additional parameter error", e); - } - } + AuthorizationRequest.Builder builder = new AuthorizationRequest.Builder( + config, + oauth2Options.getAppId(), + oauth2Options.getResponseType(), + Uri.parse(oauth2Options.getRedirectUrl()) + ); - AuthorizationRequest req = builder.build(); + // app auth always uses a state + if (oauth2Options.getState() != null) { + builder.setState(oauth2Options.getState()); + } + builder.setScope(oauth2Options.getScope()); + if (oauth2Options.isPkceEnabled()) { + builder.setCodeVerifier(oauth2Options.getPkceCodeVerifier()); + } else { + builder.setCodeVerifier(null); + } + if (oauth2Options.getPrompt() != null) { + builder.setPrompt(oauth2Options.getPrompt()); + } + if (oauth2Options.getLoginHint() != null) { + builder.setLoginHint(oauth2Options.getLoginHint()); + } + if (oauth2Options.getResponseMode() != null) { + builder.setResponseMode(oauth2Options.getResponseMode()); + } + if (oauth2Options.getDisplay() != null) { + builder.setDisplay(oauth2Options.getDisplay()); + } - this.authService = new AuthorizationService(getContext()); + if (oauth2Options.getAdditionalParameters() != null) { try { - Intent authIntent = this.authService.getAuthorizationRequestIntent(req); - this.bridge.saveCall(call); - startActivityForResult(call, authIntent, "handleIntentResult"); - } catch (ActivityNotFoundException e) { - call.reject(ERR_ANDROID_NO_BROWSER, e); - } catch (Exception e) { - Log.e(getLogTag(), "Unexpected exception on open browser for authorization request!"); - call.reject(ERR_GENERAL, e); + Map additional = oauth2Options.getAdditionalParameters(); + if (oauth2Options.getParRequestUri() != null) { + if (additional == null) { + additional = new java.util.HashMap<>(); + } else { + additional = new java.util.HashMap<>(additional); + } + additional.put("request_uri", oauth2Options.getParRequestUri()); + } + builder.setAdditionalParameters(additional); + } catch (IllegalArgumentException e) { + // ignore all additional parameter on error + Log.e(getLogTag(), "Additional parameter error", e); } + } else if (oauth2Options.getParRequestUri() != null) { + Map additional = new java.util.HashMap<>(); + additional.put("request_uri", oauth2Options.getParRequestUri()); + builder.setAdditionalParameters(additional); + } + + AuthorizationRequest req = builder.build(); + + this.authService = new AuthorizationService(getContext()); + try { + Intent authIntent = this.authService.getAuthorizationRequestIntent(req); + this.bridge.saveCall(call); + startActivityForResult(call, authIntent, "handleIntentResult"); + } catch (ActivityNotFoundException e) { + call.reject(ERR_ANDROID_NO_BROWSER, e); + } catch (Exception e) { + Log.e(getLogTag(), "Unexpected exception on open browser for authorization request!"); + call.reject(ERR_GENERAL, e); } } @@ -503,6 +526,7 @@ OAuth2Options buildAuthenticateOptions(JSObject callData) { Boolean logsEnabled = ConfigUtils.getOverwrittenAndroidParam(Boolean.class, callData, PARAM_LOGS_ENABLED); o.setLogsEnabled(logsEnabled != null && logsEnabled); o.setResourceUrl(ConfigUtils.trimToNull(ConfigUtils.getOverwrittenAndroidParam(String.class, callData, PARAM_RESOURCE_URL))); + o.setParEndpoint(ConfigUtils.trimToNull(ConfigUtils.getOverwrittenAndroidParam(String.class, callData, PARAM_PAR_ENDPOINT))); o.setAccessTokenEndpoint( ConfigUtils.trimToNull(ConfigUtils.getOverwrittenAndroidParam(String.class, callData, PARAM_ACCESS_TOKEN_ENDPOINT)) ); diff --git a/android/src/main/java/com/getcapacitor/community/genericoauth2/OAuth2Options.java b/android/src/main/java/com/getcapacitor/community/genericoauth2/OAuth2Options.java index 9bd064a0..d44e67fb 100644 --- a/android/src/main/java/com/getcapacitor/community/genericoauth2/OAuth2Options.java +++ b/android/src/main/java/com/getcapacitor/community/genericoauth2/OAuth2Options.java @@ -17,6 +17,8 @@ public class OAuth2Options { private String accessTokenEndpoint; private String resourceUrl; private Map additionalResourceHeaders; + private String parEndpoint; + private String parRequestUri; private boolean pkceEnabled; private boolean logsEnabled; @@ -67,6 +69,22 @@ public void setResourceUrl(String resourceUrl) { this.resourceUrl = resourceUrl; } + public String getParEndpoint() { + return parEndpoint; + } + + public void setParEndpoint(String parEndpoint) { + this.parEndpoint = parEndpoint; + } + + public String getParRequestUri() { + return parRequestUri; + } + + public void setParRequestUri(String parRequestUri) { + this.parRequestUri = parRequestUri; + } + public boolean isLogsEnabled() { return logsEnabled; } diff --git a/android/src/main/java/com/getcapacitor/community/genericoauth2/ParRequestAsyncTask.java b/android/src/main/java/com/getcapacitor/community/genericoauth2/ParRequestAsyncTask.java new file mode 100644 index 00000000..77d5f12f --- /dev/null +++ b/android/src/main/java/com/getcapacitor/community/genericoauth2/ParRequestAsyncTask.java @@ -0,0 +1,192 @@ +package com.getcapacitor.community.genericoauth2; + +import android.os.AsyncTask; +import android.util.Log; +import com.getcapacitor.PluginCall; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLEncoder; +import java.util.LinkedHashMap; +import java.util.Map; +import org.json.JSONObject; + +class ParRequestAsyncTask extends AsyncTask { + + private static final String ERR_PAR_FAILED = "ERR_PAR_FAILED"; + + private final PluginCall pluginCall; + private final OAuth2Options options; + private final String logTag; + private final GenericOAuth2Plugin plugin; + + ParRequestAsyncTask(PluginCall pluginCall, OAuth2Options options, String logTag, GenericOAuth2Plugin plugin) { + this.pluginCall = pluginCall; + this.options = options; + this.logTag = logTag; + this.plugin = plugin; + } + + @Override + protected ParRequestResult doInBackground(Void... voids) { + ParRequestResult result = new ParRequestResult(); + + String parEndpoint = options.getParEndpoint(); + if (parEndpoint == null) { + return result; + } + + try { + URL url = new URL(parEndpoint); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8"); + + Map params = new LinkedHashMap<>(); + params.put("client_id", options.getAppId()); + params.put("response_type", options.getResponseType()); + if (options.getRedirectUrl() != null) { + params.put("redirect_uri", options.getRedirectUrl()); + } + if (options.getScope() != null) { + params.put("scope", options.getScope()); + } + if (options.getState() != null) { + params.put("state", options.getState()); + } + + if (options.isPkceEnabled() && options.getPkceCodeVerifier() != null) { + String challenge = ConfigUtils.derivePkceCodeChallenge(options.getPkceCodeVerifier()); + params.put("code_challenge", challenge); + params.put("code_challenge_method", "S256"); + } + + if (options.getAdditionalParameters() != null) { + for (Map.Entry entry : options.getAdditionalParameters().entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + if (key != null && key.trim().length() > 0 && value != null && value.trim().length() > 0) { + if (!params.containsKey(key)) { + params.put(key, value); + } + } + } + } + + StringBuilder bodyBuilder = new StringBuilder(); + try { + for (Map.Entry entry : params.entrySet()) { + if (bodyBuilder.length() > 0) { + bodyBuilder.append('&'); + } + bodyBuilder + .append(URLEncoder.encode(entry.getKey(), "UTF-8")) + .append('=') + .append(URLEncoder.encode(entry.getValue(), "UTF-8")); + } + } catch (Exception e) { + Log.e(logTag, "Error encoding PAR parameters", e); + } + + String body = bodyBuilder.toString(); + if (options.isLogsEnabled()) { + Log.i(logTag, "PAR request: POST " + parEndpoint); + } + + try (OutputStream os = conn.getOutputStream()) { + os.write(body.getBytes("UTF-8")); + } + + InputStream is = null; + String responseBody = null; + try { + if (conn.getResponseCode() >= HttpURLConnection.HTTP_OK && conn.getResponseCode() < HttpURLConnection.HTTP_MULT_CHOICE) { + is = conn.getInputStream(); + } else { + is = conn.getErrorStream(); + } + responseBody = readInputStream(is); + } finally { + if (is != null) { + is.close(); + } + conn.disconnect(); + } + + if (conn.getResponseCode() >= HttpURLConnection.HTTP_OK && conn.getResponseCode() < HttpURLConnection.HTTP_MULT_CHOICE) { + try { + JSONObject json = new JSONObject(responseBody != null ? responseBody : "{}"); + String requestUri = + json.optString("request_uri", null) != null + ? json.optString("request_uri", null) + : (json.optString("requestUri", null) != null ? json.optString("requestUri", null) : json.optString("request-uri", null)); + if (requestUri == null || requestUri.trim().length() == 0) { + result.setError(true); + result.setErrorMsg("PAR_FAILED: missing request_uri in response"); + } else { + result.setRequestUri(requestUri); + } + } catch (Exception e) { + Log.e(logTag, "PAR response no valid json.", e); + result.setError(true); + result.setErrorMsg("PAR_FAILED: invalid JSON response"); + } + } else { + String message = "PAR_FAILED: HTTP " + conn.getResponseCode(); + try { + JSONObject json = new JSONObject(responseBody != null ? responseBody : "{}"); + if (json.has("error")) { + message += " " + json.optString("error"); + if (json.has("error_description")) { + message += " - " + json.optString("error_description"); + } + } + } catch (Exception ignore) {} + result.setError(true); + result.setErrorMsg(message); + } + } catch (MalformedURLException e) { + Log.e(logTag, "Invalid PAR endpoint url '" + options.getParEndpoint() + "'", e); + result.setError(true); + result.setErrorMsg("PAR_FAILED: invalid PAR endpoint url"); + } catch (IOException e) { + Log.e(logTag, "Unexpected error during PAR request", e); + result.setError(true); + result.setErrorMsg("PAR_FAILED: network error"); + } + + return result; + } + + @Override + protected void onPostExecute(ParRequestResult result) { + if (result != null && !result.isError()) { + options.setParRequestUri(result.getRequestUri()); + plugin.startAuthorization(pluginCall); + } else if (result != null) { + Log.e(logTag, result.getErrorMsg()); + pluginCall.reject(ERR_PAR_FAILED, result.getErrorMsg()); + } else { + pluginCall.reject(ERR_PAR_FAILED); + } + } + + private static String readInputStream(InputStream in) throws IOException { + try (BufferedReader br = new BufferedReader(new InputStreamReader(in))) { + char[] buffer = new char[1024]; + StringBuilder sb = new StringBuilder(); + int readCount; + while ((readCount = br.read(buffer)) != -1) { + sb.append(buffer, 0, readCount); + } + return sb.toString(); + } + } +} + diff --git a/android/src/main/java/com/getcapacitor/community/genericoauth2/ParRequestResult.java b/android/src/main/java/com/getcapacitor/community/genericoauth2/ParRequestResult.java new file mode 100644 index 00000000..3ea0121a --- /dev/null +++ b/android/src/main/java/com/getcapacitor/community/genericoauth2/ParRequestResult.java @@ -0,0 +1,33 @@ +package com.getcapacitor.community.genericoauth2; + +class ParRequestResult { + + private boolean error; + private String errorMsg; + private String requestUri; + + public boolean isError() { + return error; + } + + public void setError(boolean error) { + this.error = error; + } + + public String getErrorMsg() { + return errorMsg; + } + + public void setErrorMsg(String errorMsg) { + this.errorMsg = errorMsg; + } + + public String getRequestUri() { + return requestUri; + } + + public void setRequestUri(String requestUri) { + this.requestUri = requestUri; + } +} + diff --git a/ios/Plugin/GenericOAuth2Plugin.swift b/ios/Plugin/GenericOAuth2Plugin.swift index 633e7c18..b919b4b2 100644 --- a/ios/Plugin/GenericOAuth2Plugin.swift +++ b/ios/Plugin/GenericOAuth2Plugin.swift @@ -33,6 +33,7 @@ public class GenericOAuth2Plugin: CAPPlugin { let PARAM_ADDITIONAL_PARAMETERS = "additionalParameters" let PARAM_CUSTOM_HANDLER_CLASS = "ios.customHandlerClass" + let PARAM_PAR_ENDPOINT = "parEndpoint" let PARAM_SCOPE = "scope" let PARAM_STATE = "state" let PARAM_PKCE_ENABLED = "pkceEnabled" @@ -57,6 +58,7 @@ public class GenericOAuth2Plugin: CAPPlugin { let ERR_STATES_NOT_MATCH = "ERR_STATES_NOT_MATCH" let ERR_NO_AUTHORIZATION_CODE = "ERR_NO_AUTHORIZATION_CODE" let ERR_AUTHORIZATION_FAILED = "ERR_AUTHORIZATION_FAILED" + let ERR_PAR_FAILED = "ERR_PAR_FAILED" struct SharedConstants { static let ERR_USER_CANCELLED = "USER_CANCELLED" @@ -291,28 +293,127 @@ public class GenericOAuth2Plugin: CAPPlugin { let requestState = getOverwritableString(call, PARAM_STATE) ?? generateRandom(withLength: 20) let pkceEnabled: Bool = getOverwritable(call, PARAM_PKCE_ENABLED) as? Bool ?? false - // if response type is code and pkce is not disabled + let parEndpoint = getOverwritableString(call, PARAM_PAR_ENDPOINT) + + let scope = getOverwritableString(call, PARAM_SCOPE) ?? "" + + var pkceCodeVerifier: String? + var pkceCodeChallenge: String? if pkceEnabled { - let pkceCodeVerifier = generateRandom(withLength: 64) - let pkceCodeChallenge = pkceCodeVerifier.sha256().base64() - - oauthSwift.authorize( - withCallbackURL: redirectUrl, - scope: getOverwritableString(call, PARAM_SCOPE) ?? "", - state: requestState, - codeChallenge: pkceCodeChallenge, - codeVerifier: pkceCodeVerifier, - parameters: additionalParameters) { result in - self.handleAuthorizationResult(result, call, responseType, requestState, logsEnabled, resourceUrl) + pkceCodeVerifier = generateRandom(withLength: 64) + pkceCodeChallenge = pkceCodeVerifier!.sha256().base64() + } + + func startAuthorization(withRequestUri requestUri: String?) { + var parameters = additionalParameters + if let requestUri = requestUri { + parameters["request_uri"] = requestUri } - } else { - oauthSwift.authorize( - withCallbackURL: redirectUrl, - scope: getOverwritableString(call, PARAM_SCOPE) ?? "", - state: requestState, - parameters: additionalParameters) { result in - self.handleAuthorizationResult(result, call, responseType, requestState, logsEnabled, resourceUrl) + + if pkceEnabled, let verifier = pkceCodeVerifier, let challenge = pkceCodeChallenge { + oauthSwift.authorize( + withCallbackURL: redirectUrl, + scope: scope, + state: requestState, + codeChallenge: challenge, + codeVerifier: verifier, + parameters: parameters) { result in + self.handleAuthorizationResult(result, call, responseType, requestState, logsEnabled, resourceUrl) + } + } else { + oauthSwift.authorize( + withCallbackURL: redirectUrl, + scope: scope, + state: requestState, + parameters: parameters) { result in + self.handleAuthorizationResult(result, call, responseType, requestState, logsEnabled, resourceUrl) + } + } + } + + if let parEndpoint = parEndpoint { + var parParams: [String: String] = [:] + parParams["client_id"] = appId + parParams["response_type"] = responseType + parParams["redirect_uri"] = redirectUrl + if !scope.isEmpty { + parParams["scope"] = scope + } + parParams["state"] = requestState + if pkceEnabled, let challenge = pkceCodeChallenge { + parParams["code_challenge"] = challenge + parParams["code_challenge_method"] = "S256" + } + for (key, value) in additionalParameters where !key.isEmpty && !value.isEmpty && parParams[key] == nil { + parParams[key] = value + } + + if logsEnabled { + log("PAR request: POST \(parEndpoint)") } + + var request = URLRequest(url: URL(string: parEndpoint)!) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded;charset=UTF-8", forHTTPHeaderField: "Content-Type") + request.httpBody = parParams + .map { key, value in + "\(key.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? key)=\(value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value)" + } + .joined(separator: "&") + .data(using: .utf8) + + URLSession.shared.dataTask(with: request) { data, response, error in + if let error = error { + self.log("PAR request failed: \(error.localizedDescription)") + call.reject(self.ERR_PAR_FAILED, error.localizedDescription) + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + call.reject(self.ERR_PAR_FAILED, "Invalid PAR response") + return + } + + guard let data = data else { + call.reject(self.ERR_PAR_FAILED, "Empty PAR response") + return + } + + if logsEnabled { + self.logDataObj("PAR response:", data) + } + + if (200..<300).contains(httpResponse.statusCode) { + do { + let jsonObj = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + let requestUri = (jsonObj?["request_uri"] as? String) ?? + (jsonObj?["requestUri"] as? String) ?? + (jsonObj?["request-uri"] as? String) + if let requestUri = requestUri, !requestUri.isEmpty { + DispatchQueue.main.async { + startAuthorization(withRequestUri: requestUri) + } + } else { + call.reject(self.ERR_PAR_FAILED, "PAR_FAILED: missing request_uri in response") + } + } catch { + call.reject(self.ERR_PAR_FAILED, "PAR_FAILED: invalid JSON response") + } + } else { + var message = "PAR_FAILED: HTTP \(httpResponse.statusCode)" + if let jsonObj = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { + if let error = jsonObj["error"] as? String { + message += " \(error)" + if let desc = jsonObj["error_description"] as? String { + message += " - \(desc)" + } + } + } + call.reject(self.ERR_PAR_FAILED, message) + } + }.resume() + } else { + startAuthorization(withRequestUri: nil) } } diff --git a/src/definitions.ts b/src/definitions.ts index 20efbb91..6e104688 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -92,6 +92,12 @@ export interface OAuth2AuthenticateBaseOptions { * Protected resource url. For authentication you only need the basic user details. */ resourceUrl?: string; + /** + * Pushed Authorization Request (PAR) endpoint. If set, the plugin will + * perform a PAR call before starting the authorization flow and will use + * the returned request_uri for the authorization request. + */ + parEndpoint?: string; /** * Enable PKCE if you need it. */ diff --git a/src/web-utils.ts b/src/web-utils.ts index 767a3b21..c555c107 100644 --- a/src/web-utils.ts +++ b/src/web-utils.ts @@ -24,6 +24,19 @@ export class WebUtils { * Public only for testing */ static getAuthorizationUrl(options: WebOptions): string { + // If a PAR request_uri is present, build a minimal authorization URL + // that uses the pushed authorization request instead of sending all + // parameters again. + if (options.parRequestUri) { + let url = + options.authorizationBaseUrl + + '?client_id=' + + options.appId + + '&request_uri=' + + encodeURIComponent(options.parRequestUri); + return encodeURI(url); + } + let url = options.authorizationBaseUrl + '?client_id=' + options.appId; url += '&response_type=' + options.responseType; @@ -90,6 +103,98 @@ export class WebUtils { return window.sessionStorage.getItem(`I_Capacitor_GenericOAuth2Plugin_PKCE`); } + static async performPar(options: WebOptions): Promise { + if (!options.parEndpoint) { + return; + } + + const params: { [key: string]: string } = {}; + + params['client_id'] = options.appId; + params['response_type'] = options.responseType; + if (options.redirectUrl) { + params['redirect_uri'] = options.redirectUrl; + } + if (options.scope) { + params['scope'] = options.scope; + } + if (options.state) { + params['state'] = options.state; + } + + if (options.pkceCodeChallenge) { + params['code_challenge'] = options.pkceCodeChallenge; + if (options.pkceCodeChallengeMethod) { + params['code_challenge_method'] = options.pkceCodeChallengeMethod; + } + } + + if (options.additionalParameters) { + for (const key in options.additionalParameters) { + const value = options.additionalParameters[key]; + if (key && key.trim().length > 0 && value && value.trim().length > 0) { + if (!(key in params)) { + params[key] = value; + } + } + } + } + + const body = Object.keys(params) + .map( + key => + encodeURIComponent(key) + '=' + encodeURIComponent(params[key] ?? ''), + ) + .join('&'); + + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', options.parEndpoint!, true); + xhr.setRequestHeader( + 'Content-Type', + 'application/x-www-form-urlencoded;charset=UTF-8', + ); + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + const json = JSON.parse(xhr.responseText || '{}'); + const requestUri = + json.request_uri || json.requestUri || json['request-uri']; + if (!requestUri || typeof requestUri !== 'string') { + reject(new Error('PAR_FAILED: missing request_uri in response')); + return; + } + options.parRequestUri = requestUri; + resolve(); + } catch (e) { + reject(new Error('PAR_FAILED: invalid JSON response')); + } + } else { + let message = `PAR_FAILED: HTTP ${xhr.status}`; + try { + const json = JSON.parse(xhr.responseText || '{}'); + if (json.error) { + message += ` ${json.error}`; + if (json.error_description) { + message += ` - ${json.error_description}`; + } + } + } catch { + // ignore, fall back to generic message + } + reject(new Error(message)); + } + }; + + xhr.onerror = () => { + reject(new Error('PAR_FAILED: network error')); + }; + + xhr.send(body); + }); + } + /** * Public only for testing */ @@ -184,6 +289,11 @@ export class WebUtils { 'accessTokenEndpoint', ); + webOptions.parEndpoint = this.getOverwritableValue( + configOptions, + 'parEndpoint', + ); + webOptions.pkceEnabled = this.getOverwritableValue( configOptions, 'pkceEnabled', @@ -344,6 +454,9 @@ export class WebOptions { windowOptions: string; windowTarget = '_blank'; + parEndpoint?: string; + parRequestUri?: string; + pkceEnabled: boolean; pkceCodeVerifier: string; pkceCodeChallenge: string; diff --git a/src/web.ts b/src/web.ts index 03c0f6f7..b7f8dc17 100644 --- a/src/web.ts +++ b/src/web.ts @@ -58,6 +58,14 @@ export class GenericOAuth2Web extends WebPlugin implements GenericOAuth2Plugin { ); this.webOptions = await WebUtils.buildWebOptions(options); + if (this.webOptions.parEndpoint) { + try { + await WebUtils.performPar(this.webOptions); + } catch (e: any) { + this.closeWindow(); + return Promise.reject(e); + } + } return new Promise((resolve, reject) => { // validate if (!this.webOptions.appId || this.webOptions.appId.length == 0) { From 0d00cbc0b328b0cae4ed947b92bc972b54dbb1cc Mon Sep 17 00:00:00 2001 From: Sven Schultze Date: Thu, 11 Dec 2025 14:40:20 +0100 Subject: [PATCH 2/4] Run build on install (prepare) --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index b1cb1642..8580b05b 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,8 @@ "test": "jest", "removePacked": "rimraf -g capacitor-community-generic-oauth2-*.tgz", "publish:locally": "npm run removePacked && npm run build && npm pack", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "prepare": "npm run build" }, "devDependencies": { "@capacitor/android": "7.4.2", From f8c60e47054bff3b0132b4791884e37b29c69354 Mon Sep 17 00:00:00 2001 From: Sven Schultze Date: Mon, 15 Dec 2025 13:10:44 +0100 Subject: [PATCH 3/4] Delete PKCE_PAR.md --- PKCE_PAR.md | 131 ---------------------------------------------------- 1 file changed, 131 deletions(-) delete mode 100644 PKCE_PAR.md diff --git a/PKCE_PAR.md b/PKCE_PAR.md deleted file mode 100644 index 28f727bb..00000000 --- a/PKCE_PAR.md +++ /dev/null @@ -1,131 +0,0 @@ -# PAR + PKCE Behavior Summary - -This fork extends the Generic OAuth2 Capacitor plugin to support **Pushed Authorization Requests (PAR)** with **PKCE** on all platforms (Web, Android, iOS) while keeping the existing API backward compatible. - -## New configuration - -- `OAuth2AuthenticateBaseOptions.parEndpoint?: string` - - URL of the provider's PAR endpoint (`pushed_authorization_request_endpoint`). - - Can be set globally, or per platform using `web.parEndpoint`, `android.parEndpoint`, or `ios.parEndpoint`. - -Behavior: - -- If `parEndpoint` is **unset** for a platform: - - Existing behavior is preserved (no PAR). - - PKCE, authorization, and token flows work as before. -- If `parEndpoint` is **set** for a platform: - - The plugin performs a PAR call before the authorization redirect and uses the returned `request_uri` when starting the authorization flow. - -## Single PKCE verifier per auth attempt - -Across all platforms the plugin now guarantees that: - -- When `pkceEnabled` is `true`, a single **PKCE code verifier** is generated for the auth attempt. -- The **code challenge** used in the PAR request is always derived from this same verifier. -- The same verifier is then used at the **token exchange** step. - -Platform details: - -- **Web** - - `WebUtils.buildWebOptions`: - - Generates `pkceCodeVerifier` once (or reuses it from `sessionStorage`). - - Computes `pkceCodeChallenge` and `pkceCodeChallengeMethod`. - - `WebUtils.performPar`: - - Sends `client_id`, `response_type`, `redirect_uri`, `scope`, `state`, and PKCE (`code_challenge`, `code_challenge_method`) plus `additionalParameters` to `parEndpoint` via `POST` (`application/x-www-form-urlencoded`). - - Reads `request_uri` from the JSON response and stores it. - - `WebUtils.getAuthorizationUrl`: - - If `request_uri` is present, builds an authorization URL with `client_id` and `request_uri` instead of repeating all parameters. - - `getTokenEndpointData`: - - Uses the same `pkceCodeVerifier` as the `code_verifier` in the token request. - -- **Android (AppAuth)** - - `OAuth2Options`: - - Holds `parEndpoint`, `parRequestUri`, `pkceEnabled`, and `pkceCodeVerifier` (generated once per auth attempt). - - `ParRequestAsyncTask`: - - Sends `client_id`, `response_type`, `redirect_uri`, `scope`, `state`, PKCE (`code_challenge` derived from `pkceCodeVerifier` with S256, or plain as fallback), and `additionalParameters` to `parEndpoint` via `POST` (`application/x-www-form-urlencoded`). - - Stores the returned `request_uri` on `OAuth2Options`. - - `GenericOAuth2Plugin.startAuthorization`: - - Builds the usual `AuthorizationRequest.Builder` (state, scope, etc). - - Always passes the same `pkceCodeVerifier` to `setCodeVerifier` when PKCE is enabled. - - Includes `request_uri` in the additional parameters when PAR is used. - -- **iOS (OAuthSwift)** - - `GenericOAuth2Plugin`: - - Generates `requestState` and, when `pkceEnabled`, generates a single `pkceCodeVerifier` and `pkceCodeChallenge` per auth attempt. - - If `parEndpoint` is configured: - - Builds a PAR request with `client_id`, `response_type`, `redirect_uri`, `scope`, `state`, PKCE (`code_challenge`, `code_challenge_method=S256` when enabled), plus `additionalParameters`. - - Sends `POST` (`application/x-www-form-urlencoded`) to `parEndpoint` and extracts `request_uri` from the JSON response. - - Calls `oauthSwift.authorize`: - - Adds `request_uri` to the `parameters` dictionary when PAR is used. - - Passes the same PKCE `codeChallenge`/`codeVerifier` pair to `authorize`, which is reused by OAuthSwift for the token request. - -## Error handling - -If PAR is enabled and fails, the plugin fails fast before opening a browser / external UI: - -- **Web** - - `WebUtils.performPar` rejects with an `Error` whose message starts with `PAR_FAILED:`: - - `PAR_FAILED: missing request_uri in response` - - `PAR_FAILED: invalid JSON response` - - `PAR_FAILED: HTTP [error - error_description]` - - `PAR_FAILED: network error` - -- **Android** - - `ParRequestAsyncTask` rejects the call with: - - Error code: `ERR_PAR_FAILED` - - Message: `PAR_FAILED: ...` (HTTP status, JSON `error` / `error_description`, or network/format issues). - -- **iOS** - - PAR failures reject the CAP plugin call with: - - Error code: `ERR_PAR_FAILED` - - Message: `PAR_FAILED: ...` (HTTP status, JSON parsing issues, or network error). - -Logging: - -- The existing `logsEnabled` flag continues to control logging behavior. -- When `logsEnabled` is `true`, PAR requests and responses are logged (without printing secrets like client secret, which is not used in this flow). - -## Backward compatibility - -- When `parEndpoint` is **not configured**: - - Web, Android, and iOS behave exactly as in the upstream plugin: - - No PAR call is made. - - PKCE (when enabled) works unchanged. -- When `parEndpoint` **is configured**: - - The only behavioral difference is: - - The plugin performs a PAR call before redirecting to the authorization endpoint. - - The authorization redirect uses the resulting `request_uri` alongside the existing PKCE and state handling. - -## Example configuration (Authelia + PAR + PKCE) - -```ts -const options: OAuth2AuthenticateOptions = { - appId: 'my-client-id', - authorizationBaseUrl: 'https://auth.example.com/oauth2/authorize', - accessTokenEndpoint: 'https://auth.example.com/oauth2/token', - parEndpoint: 'https://auth.example.com/oauth2/par', - redirectUrl: 'myapp://callback', - responseType: 'code', - scope: 'openid profile email offline_access', - pkceEnabled: true, - logsEnabled: true, - - // Optional per-platform overrides - web: { - parEndpoint: 'https://auth.example.com/oauth2/par', - }, - android: { - parEndpoint: 'https://auth.example.com/oauth2/par', - }, - ios: { - parEndpoint: 'https://auth.example.com/oauth2/par', - }, - - additionalParameters: { - // any provider-specific params, e.g. audience - }, -}; -``` - -This setup will use PAR + PKCE correctly with providers like Authelia that require both PAR and PKCE, while still allowing non-PAR providers to work by simply omitting `parEndpoint`. - From 738f3bc8f9f03ae6363fd7ae3203df625461ad45 Mon Sep 17 00:00:00 2001 From: Sven Schultze Date: Mon, 15 Dec 2025 13:11:59 +0100 Subject: [PATCH 4/4] Remove npm run prepare --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index 8580b05b..b1cb1642 100644 --- a/package.json +++ b/package.json @@ -46,8 +46,7 @@ "test": "jest", "removePacked": "rimraf -g capacitor-community-generic-oauth2-*.tgz", "publish:locally": "npm run removePacked && npm run build && npm pack", - "prepublishOnly": "npm run build", - "prepare": "npm run build" + "prepublishOnly": "npm run build" }, "devDependencies": { "@capacitor/android": "7.4.2",