diff --git a/package.json b/package.json index 143cd234a..fa563280e 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "inferno-router": "^8.2.3", "inferno-server": "^8.2.3", "jwt-decode": "^4.0.0", - "lemmy-js-client": "0.20.0-alpha.17", + "lemmy-js-client": "0.20.0-pkce.1", "lodash.isequal": "^4.5.0", "markdown-it": "^14.1.0", "markdown-it-bidi": "^0.2.0", diff --git a/src/shared/components/common/modal/create-or-edit-oauth-provider-modal.tsx b/src/shared/components/common/modal/create-or-edit-oauth-provider-modal.tsx index a0112f27c..e7a63e44f 100644 --- a/src/shared/components/common/modal/create-or-edit-oauth-provider-modal.tsx +++ b/src/shared/components/common/modal/create-or-edit-oauth-provider-modal.tsx @@ -51,6 +51,7 @@ interface ProviderTextFieldProps extends ProviderFieldProps { type ProviderBooleanProperties = | "enabled" + | "use_pkce" | "account_linking_enabled" | "auto_verify_email"; @@ -337,6 +338,18 @@ export default class CreateOrEditOAuthProviderModal extends Component< handleBooleanPropertyChange, )} /> + <ProviderCheckboxField + id="use-pkce" + i18nKey="use_pkce" + checked={provider?.use_pkce} + onInput={linkEvent( + { + modal: this, + property: "use_pkce", + }, + handleBooleanPropertyChange, + )} + /> <ProviderCheckboxField id="oauth-enabled" i18nKey="oauth_enabled" diff --git a/src/shared/components/home/login.tsx b/src/shared/components/home/login.tsx index 01f3d781f..84fdbddff 100644 --- a/src/shared/components/home/login.tsx +++ b/src/shared/components/home/login.tsx @@ -25,6 +25,10 @@ import { UnreadCounterService } from "../../services"; import { RouteData } from "../../interfaces"; import { IRoutePropsWithFetch } from "../../routes"; import { simpleScrollMixin } from "../mixins/scroll-mixin"; +import { + generateCodeVerifier, + createCodeChallenge, +} from "@utils/helpers/oauth"; interface LoginProps { prev?: string; @@ -126,16 +130,32 @@ export async function handleUseOAuthProvider(params: { const redirectUri = `${window.location.origin}/oauth/callback`; const state = crypto.randomUUID(); + + let codeVerifier: string | undefined; + if (params.oauth_provider.use_pkce) { + codeVerifier = generateCodeVerifier(); + } + let codeChallenge: string | undefined; + if (codeVerifier) { + codeChallenge = await createCodeChallenge(codeVerifier); + } + + const queryPairs = [ + `client_id=${encodeURIComponent(params.oauth_provider.client_id)}`, + `response_type=code`, + `scope=${encodeURIComponent(params.oauth_provider.scopes)}`, + `redirect_uri=${encodeURIComponent(redirectUri)}`, + `state=${state}`, + ...(codeChallenge + ? [ + `code_challenge=${encodeURIComponent(codeChallenge)}`, + "code_challenge_method=S256", + ] + : []), + ]; + const requestUri = - params.oauth_provider.authorization_endpoint + - "?" + - [ - `client_id=${encodeURIComponent(params.oauth_provider.client_id)}`, - `response_type=code`, - `scope=${encodeURIComponent(params.oauth_provider.scopes)}`, - `redirect_uri=${encodeURIComponent(redirectUri)}`, - `state=${state}`, - ].join("&"); + params.oauth_provider.authorization_endpoint + "?" + queryPairs.join("&"); // store state in local storage localStorage.setItem( @@ -149,6 +169,7 @@ export async function handleUseOAuthProvider(params: { answer: params.answer, show_nsfw: params.show_nsfw, expires_at: Date.now() + 5 * 60_000, + ...(codeVerifier ? { pkce_code_verifier: codeVerifier } : {}), }), ); diff --git a/src/shared/components/home/oauth/oauth-callback.tsx b/src/shared/components/home/oauth/oauth-callback.tsx index 8d8ea3db8..2ebdf1b02 100644 --- a/src/shared/components/home/oauth/oauth-callback.tsx +++ b/src/shared/components/home/oauth/oauth-callback.tsx @@ -79,6 +79,9 @@ export class OAuthCallback extends Component<OAuthCallbackRouteProps, State> { show_nsfw: local_oauth_state.show_nsfw, username: local_oauth_state.username, answer: local_oauth_state.answer, + ...(local_oauth_state?.pkce_code_verifier && { + pkce_code_verifier: local_oauth_state.pkce_code_verifier, + }), }); switch (loginRes.state) { diff --git a/src/shared/components/home/oauth/oauth-provider-list-item.tsx b/src/shared/components/home/oauth/oauth-provider-list-item.tsx index e87ae3f1f..b538e5a2c 100644 --- a/src/shared/components/home/oauth/oauth-provider-list-item.tsx +++ b/src/shared/components/home/oauth/oauth-provider-list-item.tsx @@ -86,6 +86,10 @@ export default function OAuthProviderListItem({ i18nKey="oauth_account_linking_enabled" data={boolToYesNo(provider.account_linking_enabled)} /> + <TextInfoField + i18nKey="use_pkce" + data={boolToYesNo(provider.use_pkce)} + /> <TextInfoField i18nKey="oauth_enabled" data={boolToYesNo(provider.enabled)} diff --git a/src/shared/components/home/oauth/oauth-providers-tab.tsx b/src/shared/components/home/oauth/oauth-providers-tab.tsx index e6efa5378..4ffbbacad 100644 --- a/src/shared/components/home/oauth/oauth-providers-tab.tsx +++ b/src/shared/components/home/oauth/oauth-providers-tab.tsx @@ -36,6 +36,7 @@ const PRESET_OAUTH_PROVIDERS: ProviderToEdit[] = [ scopes: "openid email", auto_verify_email: true, account_linking_enabled: true, + use_pkce: true, enabled: true, }, // additional preset providers can be added here diff --git a/src/shared/utils/helpers/oauth.ts b/src/shared/utils/helpers/oauth.ts new file mode 100644 index 000000000..31e24f5ad --- /dev/null +++ b/src/shared/utils/helpers/oauth.ts @@ -0,0 +1,19 @@ +export function base64URLEncode(buffer: Uint8Array | ArrayBuffer) { + return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer))) + .replace(/\//g, "_") + .replace(/\+/g, "-") + .replace(/=/g, ""); +} + +export function generateCodeVerifier(length: number = 64) { + const array = new Uint8Array(length); + window.crypto.getRandomValues(array); + return base64URLEncode(array); +} + +export async function createCodeChallenge(codeVerifier: string) { + const encoder = new TextEncoder(); + const data = encoder.encode(codeVerifier); + const digest = await window.crypto.subtle.digest("SHA-256", data); + return base64URLEncode(digest); +}