Skip to content

Commit c4bb7f6

Browse files
committed
feat: add PKCE
1 parent f9b1096 commit c4bb7f6

File tree

6 files changed

+84
-9
lines changed

6 files changed

+84
-9
lines changed

Diff for: src/shared/components/common/modal/create-or-edit-oauth-provider-modal.tsx

+13
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ interface ProviderTextFieldProps extends ProviderFieldProps {
5050
}
5151

5252
type ProviderBooleanProperties =
53+
| "use_pkce"
5354
| "enabled"
5455
| "account_linking_enabled"
5556
| "auto_verify_email";
@@ -337,6 +338,18 @@ export default class CreateOrEditOAuthProviderModal extends Component<
337338
handleBooleanPropertyChange,
338339
)}
339340
/>
341+
<ProviderCheckboxField
342+
id="use-pkce"
343+
i18nKey="use_pkce"
344+
checked={provider?.use_pkce ?? false}
345+
onInput={linkEvent(
346+
{
347+
modal: this,
348+
property: "use_pkce",
349+
},
350+
handleBooleanPropertyChange,
351+
)}
352+
/>
340353
<ProviderCheckboxField
341354
id="oauth-enabled"
342355
i18nKey="oauth_enabled"

Diff for: src/shared/components/home/login.tsx

+19-9
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { UnreadCounterService } from "../../services";
2525
import { RouteData } from "../../interfaces";
2626
import { IRoutePropsWithFetch } from "../../routes";
2727
import { simpleScrollMixin } from "../mixins/scroll-mixin";
28+
import { generatePKCE } from "@utils/helpers/oauth";
2829

2930
interface LoginProps {
3031
prev?: string;
@@ -126,22 +127,31 @@ export async function handleUseOAuthProvider(params: {
126127
const redirectUri = `${window.location.origin}/oauth/callback`;
127128

128129
const state = crypto.randomUUID();
130+
const [code_challenge, code_verifier] = await generatePKCE();
131+
132+
const queryPairs = [
133+
`client_id=${encodeURIComponent(params.oauth_provider.client_id)}`,
134+
`response_type=code`,
135+
`scope=${encodeURIComponent(params.oauth_provider.scopes)}`,
136+
`redirect_uri=${encodeURIComponent(redirectUri)}`,
137+
`state=${state}`,
138+
...(params.oauth_provider.use_pkce
139+
? [
140+
`code_challenge=${encodeURIComponent(code_challenge)}`,
141+
"code_challenge_method=S256",
142+
]
143+
: []),
144+
];
145+
129146
const requestUri =
130-
params.oauth_provider.authorization_endpoint +
131-
"?" +
132-
[
133-
`client_id=${encodeURIComponent(params.oauth_provider.client_id)}`,
134-
`response_type=code`,
135-
`scope=${encodeURIComponent(params.oauth_provider.scopes)}`,
136-
`redirect_uri=${encodeURIComponent(redirectUri)}`,
137-
`state=${state}`,
138-
].join("&");
147+
params.oauth_provider.authorization_endpoint + "?" + queryPairs.join("&");
139148

140149
// store state in local storage
141150
localStorage.setItem(
142151
"oauth_state",
143152
JSON.stringify({
144153
state,
154+
pkce_code_verifier: code_verifier,
145155
oauth_provider_id: params.oauth_provider.id,
146156
redirect_uri: redirectUri,
147157
prev: params.prev ?? "/",

Diff for: src/shared/components/home/oauth/oauth-callback.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ export class OAuthCallback extends Component<OAuthCallbackRouteProps, State> {
7979
show_nsfw: local_oauth_state.show_nsfw,
8080
username: local_oauth_state.username,
8181
answer: local_oauth_state.answer,
82+
...(local_oauth_state?.pkce_code_verifier && {
83+
pkce_code_verifier: local_oauth_state.pkce_code_verifier,
84+
}),
8285
});
8386

8487
switch (loginRes.state) {

Diff for: src/shared/components/home/oauth/oauth-provider-list-item.tsx

+4
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ export default function OAuthProviderListItem({
8686
i18nKey="oauth_account_linking_enabled"
8787
data={boolToYesNo(provider.account_linking_enabled)}
8888
/>
89+
<TextInfoField
90+
i18nKey="use_pkce"
91+
data={boolToYesNo(provider.use_pkce)}
92+
/>
8993
<TextInfoField
9094
i18nKey="oauth_enabled"
9195
data={boolToYesNo(provider.enabled)}

Diff for: src/shared/components/home/oauth/oauth-providers-tab.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const PRESET_OAUTH_PROVIDERS: ProviderToEdit[] = [
3636
scopes: "openid email",
3737
auto_verify_email: true,
3838
account_linking_enabled: true,
39+
use_pkce: true,
3940
enabled: true,
4041
},
4142
// additional preset providers can be added here

Diff for: src/shared/utils/helpers/oauth.ts

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
const PKCE_VERIFIER_LENGTH = 96;
2+
3+
const PKCE_ALPHABET =
4+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
5+
6+
const PKCE_ALGORITHM = "SHA-256";
7+
8+
function urlUnpaddedBase64Encode(value: string): string {
9+
return btoa(
10+
String.fromCharCode.apply(
11+
null,
12+
new Uint8Array(new TextEncoder().encode(value)),
13+
),
14+
)
15+
.replace(/\+/g, "-")
16+
.replace(/\//g, "_")
17+
.replace(/=+$/, "");
18+
}
19+
20+
export async function generatePKCE(): Promise<[string, string]> {
21+
const randomValues = crypto.getRandomValues(
22+
new Uint32Array(PKCE_VERIFIER_LENGTH),
23+
);
24+
25+
const code_verifier = urlUnpaddedBase64Encode(
26+
Array.from(randomValues)
27+
.map(n => PKCE_ALPHABET[n % PKCE_ALPHABET.length])
28+
.join(""),
29+
);
30+
const code_verifier_digest = await crypto.subtle.digest(
31+
PKCE_ALGORITHM,
32+
new TextEncoder().encode(code_verifier),
33+
);
34+
const code_verifier_hash = new Uint8Array(code_verifier_digest);
35+
36+
let code_challenge = "";
37+
for (let i = 0; i < code_verifier_hash.byteLength; i++) {
38+
code_challenge = code_challenge.concat(
39+
String.fromCharCode(code_verifier_hash[i]),
40+
);
41+
}
42+
43+
return [urlUnpaddedBase64Encode(code_challenge), code_verifier];
44+
}

0 commit comments

Comments
 (0)