Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions crates/assets/js/admin/proto/config.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 34 additions & 0 deletions crates/assets/js/admin/src/components/FormFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,40 @@ export function buildOptionalTextFormField(opts: TextFieldOptions) {
};
}

/// Used for repeated proto string fields, entered as a whitespace-separated list.
export function buildStringListFormField(opts: Omit<TextFieldOptions, "type">) {
return function builder(field: () => FieldApiT<string[]>) {
return (
<TextField class="w-full">
<div
class={cn("grid items-center", gapStyle)}
style={{ "grid-template-columns": "auto 1fr" }}
>
<TextFieldLabel>{opts.label()}</TextFieldLabel>

<TextFieldInput
disabled={opts.disabled ?? false}
type="text"
value={(field().state.value ?? []).join(" ")}
placeholder={opts.placeholder}
onBlur={field().handleBlur}
autocomplete={opts.autocomplete}
onChange={(e: Event) => {
const value = (e.target as HTMLInputElement).value;
field().handleChange(value.split(/\s+/).filter((s) => s !== ""));
}}
onInput={opts.onInput}
data-testid="input"
/>

<GridFieldInfo field={field()} />
<InfoColumn info={opts.info} />
</div>
</TextField>
);
};
}

export function buildSecretFormField(opts: Omit<TextFieldOptions, "type">) {
const [type, setType] = createSignal<TextFieldType>("password");

Expand Down
24 changes: 21 additions & 3 deletions crates/assets/js/admin/src/components/settings/AuthSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
buildOptionalBoolFormField,
buildOptionalSecretFormField,
buildOptionalTextFormField,
buildStringListFormField,
} from "@/components/FormFields";
import {
Accordion,
Expand Down Expand Up @@ -137,11 +138,11 @@ function proxyToConfig(proxy: AuthConfigProxy): AuthConfig {
const clientSecret = entry.state?.clientSecret?.trim();

if (clientId && clientSecret) {
config.oauthProviders[p.name] = {
config.oauthProviders[p.name] = OAuthProviderConfig.fromPartial({
providerId: p.id,

...entry.state,
};
});
} else {
console.debug("Skipping incomplete: ", entry);
}
Expand Down Expand Up @@ -177,7 +178,7 @@ function ProviderSettingsSubForm(props: {
}

const s = state.values.namedOAuthProviders[props.index].state;
setOnce({ ...s });
setOnce(s && OAuthProviderConfig.fromPartial(s));
return s;
})(),
);
Expand Down Expand Up @@ -258,6 +259,23 @@ function ProviderSettingsSubForm(props: {
>
{buildOptionalTextFormField({ label: () => <L>User API URL</L> })}
</props.form.Field>

<props.form.Field
name={`namedOAuthProviders[${props.index}].state.scopes`}
>
{buildStringListFormField({
label: () => <L>Scopes</L>,
placeholder: "openid email profile",
info: (
<p>
Space-separated scopes to request. Empty means the defaults:
"openid email profile". Only claims covered by the requested
scopes are returned, so dropping "email" requires a
username-based user identifier above.
</p>
),
})}
</props.form.Field>
</Show>
</div>

Expand Down
8 changes: 8 additions & 0 deletions crates/core/proto/config.proto
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ message OAuthProviderConfig {
optional string auth_url = 12;
optional string token_url = 13;
optional string user_api_url = 14;

// Replaces the provider's default scopes, when set. Needed for providers that
// don't offer the defaults, e.g. an OIDC provider w/o `email` or `profile`.
//
// NOTE: Claims not covered by the requested scopes won't be returned by the
// provider's user-info endpoint, i.e. dropping `email` requires a
// username-based `UserIdentifier`.
repeated string scopes = 15;
}

// What user identifier to use for new user registrations as well as
Expand Down
15 changes: 14 additions & 1 deletion crates/core/src/auth/oauth/callback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,19 @@ async fn create_user_for_external_provider(
return Err(AuthError::Unauthorized);
}

// Providers only return claims covered by the scopes we requested, so the email may be missing,
// e.g. for an OIDC provider configured w/o the `email` scope. Only username-based identifiers
// can do without one.
let email: Option<String> = match (email, user_identifier) {
(Some(email), _) => Some(email),
(None, UserIdentifier::OnlyUsername | UserIdentifier::RequireUsername) => None,
(None, _) => {
return Err(AuthError::BadRequest(
"OAuth provider returned no email address. Requires a username-based `user_identifier`",
));
}
};

let mut username: Option<String> = match (user_identifier, username) {
(UserIdentifier::OnlyEmail | UserIdentifier::Undefined, _) => None,
(
Expand Down Expand Up @@ -400,7 +413,7 @@ mod tests {
return OAuthUser {
provider_user_id: rand.clone(),
provider_id: OAuthProviderId::Test,
email: format!("email_{rand}@test.org"),
email: Some(format!("email_{rand}@test.org")),
username,
verified: true,
avatar: None,
Expand Down
4 changes: 3 additions & 1 deletion crates/core/src/auth/oauth/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
pub(crate) mod providers;

mod callback;
mod list_providers;
mod login;
Expand All @@ -13,7 +15,7 @@ use utoipa_axum::router::OpenApiRouter;

use crate::AppState;

pub(crate) use provider::{OAuthClientSettings, OAuthProvider, OAuthUser};
pub(crate) use providers::interface::{OAuthClientSettings, OAuthProvider, OAuthUser};
pub(crate) use reqwest_client::ReqwestClient;

pub fn oauth_router() -> OpenApiRouter<AppState> {
Expand Down
Loading
Loading