feat: add PKCE

This commit is contained in:
avdb13 2024-11-15 10:52:58 +00:00
parent f9b1096ded
commit c4bb7f61f8
6 changed files with 84 additions and 9 deletions

View file

@ -50,6 +50,7 @@ interface ProviderTextFieldProps extends ProviderFieldProps {
} }
type ProviderBooleanProperties = type ProviderBooleanProperties =
| "use_pkce"
| "enabled" | "enabled"
| "account_linking_enabled" | "account_linking_enabled"
| "auto_verify_email"; | "auto_verify_email";
@ -337,6 +338,18 @@ export default class CreateOrEditOAuthProviderModal extends Component<
handleBooleanPropertyChange, handleBooleanPropertyChange,
)} )}
/> />
<ProviderCheckboxField
id="use-pkce"
i18nKey="use_pkce"
checked={provider?.use_pkce ?? false}
onInput={linkEvent(
{
modal: this,
property: "use_pkce",
},
handleBooleanPropertyChange,
)}
/>
<ProviderCheckboxField <ProviderCheckboxField
id="oauth-enabled" id="oauth-enabled"
i18nKey="oauth_enabled" i18nKey="oauth_enabled"

View file

@ -25,6 +25,7 @@ import { UnreadCounterService } from "../../services";
import { RouteData } from "../../interfaces"; import { RouteData } from "../../interfaces";
import { IRoutePropsWithFetch } from "../../routes"; import { IRoutePropsWithFetch } from "../../routes";
import { simpleScrollMixin } from "../mixins/scroll-mixin"; import { simpleScrollMixin } from "../mixins/scroll-mixin";
import { generatePKCE } from "@utils/helpers/oauth";
interface LoginProps { interface LoginProps {
prev?: string; prev?: string;
@ -126,22 +127,31 @@ export async function handleUseOAuthProvider(params: {
const redirectUri = `${window.location.origin}/oauth/callback`; const redirectUri = `${window.location.origin}/oauth/callback`;
const state = crypto.randomUUID(); const state = crypto.randomUUID();
const requestUri = const [code_challenge, code_verifier] = await generatePKCE();
params.oauth_provider.authorization_endpoint +
"?" + const queryPairs = [
[
`client_id=${encodeURIComponent(params.oauth_provider.client_id)}`, `client_id=${encodeURIComponent(params.oauth_provider.client_id)}`,
`response_type=code`, `response_type=code`,
`scope=${encodeURIComponent(params.oauth_provider.scopes)}`, `scope=${encodeURIComponent(params.oauth_provider.scopes)}`,
`redirect_uri=${encodeURIComponent(redirectUri)}`, `redirect_uri=${encodeURIComponent(redirectUri)}`,
`state=${state}`, `state=${state}`,
].join("&"); ...(params.oauth_provider.use_pkce
? [
`code_challenge=${encodeURIComponent(code_challenge)}`,
"code_challenge_method=S256",
]
: []),
];
const requestUri =
params.oauth_provider.authorization_endpoint + "?" + queryPairs.join("&");
// store state in local storage // store state in local storage
localStorage.setItem( localStorage.setItem(
"oauth_state", "oauth_state",
JSON.stringify({ JSON.stringify({
state, state,
pkce_code_verifier: code_verifier,
oauth_provider_id: params.oauth_provider.id, oauth_provider_id: params.oauth_provider.id,
redirect_uri: redirectUri, redirect_uri: redirectUri,
prev: params.prev ?? "/", prev: params.prev ?? "/",

View file

@ -79,6 +79,9 @@ export class OAuthCallback extends Component<OAuthCallbackRouteProps, State> {
show_nsfw: local_oauth_state.show_nsfw, show_nsfw: local_oauth_state.show_nsfw,
username: local_oauth_state.username, username: local_oauth_state.username,
answer: local_oauth_state.answer, answer: local_oauth_state.answer,
...(local_oauth_state?.pkce_code_verifier && {
pkce_code_verifier: local_oauth_state.pkce_code_verifier,
}),
}); });
switch (loginRes.state) { switch (loginRes.state) {

View file

@ -86,6 +86,10 @@ export default function OAuthProviderListItem({
i18nKey="oauth_account_linking_enabled" i18nKey="oauth_account_linking_enabled"
data={boolToYesNo(provider.account_linking_enabled)} data={boolToYesNo(provider.account_linking_enabled)}
/> />
<TextInfoField
i18nKey="use_pkce"
data={boolToYesNo(provider.use_pkce)}
/>
<TextInfoField <TextInfoField
i18nKey="oauth_enabled" i18nKey="oauth_enabled"
data={boolToYesNo(provider.enabled)} data={boolToYesNo(provider.enabled)}

View file

@ -36,6 +36,7 @@ const PRESET_OAUTH_PROVIDERS: ProviderToEdit[] = [
scopes: "openid email", scopes: "openid email",
auto_verify_email: true, auto_verify_email: true,
account_linking_enabled: true, account_linking_enabled: true,
use_pkce: true,
enabled: true, enabled: true,
}, },
// additional preset providers can be added here // additional preset providers can be added here

View file

@ -0,0 +1,44 @@
const PKCE_VERIFIER_LENGTH = 96;
const PKCE_ALPHABET =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
const PKCE_ALGORITHM = "SHA-256";
function urlUnpaddedBase64Encode(value: string): string {
return btoa(
String.fromCharCode.apply(
null,
new Uint8Array(new TextEncoder().encode(value)),
),
)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
export async function generatePKCE(): Promise<[string, string]> {
const randomValues = crypto.getRandomValues(
new Uint32Array(PKCE_VERIFIER_LENGTH),
);
const code_verifier = urlUnpaddedBase64Encode(
Array.from(randomValues)
.map(n => PKCE_ALPHABET[n % PKCE_ALPHABET.length])
.join(""),
);
const code_verifier_digest = await crypto.subtle.digest(
PKCE_ALGORITHM,
new TextEncoder().encode(code_verifier),
);
const code_verifier_hash = new Uint8Array(code_verifier_digest);
let code_challenge = "";
for (let i = 0; i < code_verifier_hash.byteLength; i++) {
code_challenge = code_challenge.concat(
String.fromCharCode(code_verifier_hash[i]),
);
}
return [urlUnpaddedBase64Encode(code_challenge), code_verifier];
}