Skip to content

Commit 7be1027

Browse files
Add device code auth in the dashboard, fix Linux themes (#16)
* Add device code auth in the dashboard, fix Linux themes * Fix clipboard copy text
1 parent 4b3c9c2 commit 7be1027

File tree

12 files changed

+251
-19
lines changed

12 files changed

+251
-19
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/autocomplete/src/history/helpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ export const walkSubcommand = <
188188
(state, annotation) => {
189189
const { text, type } = annotation;
190190
const name =
191-
"tokenName" in annotation ? annotation.tokenName ?? text : text;
191+
"tokenName" in annotation ? (annotation.tokenName ?? text) : text;
192192

193193
if (type === TokenType.Subcommand) {
194194
if (!state.subcommand.subcommands[name]) {

apps/dashboard/src/components/installs/modal/login/index.tsx

Lines changed: 114 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,20 @@ export default function LoginModal({ next }: { next: () => void }) {
2020
z.enum(["builderId", "iam"]),
2121
midway ? "iam" : "builderId",
2222
);
23+
24+
// Since PKCE requires the ability to open a browser, we also support falling
25+
// back to device code in case of an error.
26+
const [loginMethod, setLoginMethod] = useState<"pkce" | "deviceCode">("pkce");
27+
28+
// used for pkce
2329
const [currAuthRequestId, setAuthRequestId] = useAuthRequest();
2430

25-
// console.log(tab);
31+
// used for device code
32+
const [loginCode, setLoginCode] = useState<string | null>(null);
33+
const [loginUrl, setLoginUrl] = useState<string | null>(null);
34+
const [copyToClipboardText, setCopyToClipboardText] = useState<
35+
"Copy to clipboard" | "Copied!"
36+
>("Copy to clipboard");
2637

2738
const [error, setError] = useState<string | null>(null);
2839
const [completedOnboarding] = useLocalStateZodDefault(
@@ -33,7 +44,15 @@ export default function LoginModal({ next }: { next: () => void }) {
3344
const auth = useAuth();
3445
const refreshAuth = useRefreshAuth();
3546

36-
async function handleLogin(issuerUrl?: string, region?: string) {
47+
async function handleLogin(startUrl?: string, region?: string) {
48+
if (loginMethod === "pkce") {
49+
handlePkceAuth(startUrl, region);
50+
} else {
51+
handleDeviceCodeAuth(startUrl, region);
52+
}
53+
}
54+
55+
async function handlePkceAuth(issuerUrl?: string, region?: string) {
3756
setLoginState("loading");
3857
setError(null);
3958
// We need to reset the auth request state before attempting, otherwise
@@ -53,7 +72,13 @@ export default function LoginModal({ next }: { next: () => void }) {
5372
setAuthRequestId(init.authRequestId);
5473

5574
Native.open(init.url).catch((err) => {
75+
// Reset the auth request id so that we don't present the "OAuth cancelled" error
76+
// to the user.
77+
setAuthRequestId("");
5678
console.error(err);
79+
setError(
80+
"Failed to open the browser. As an alternative, try logging in with device code.",
81+
);
5782
});
5883

5984
await Auth.finishPkceAuthorization(init)
@@ -74,6 +99,44 @@ export default function LoginModal({ next }: { next: () => void }) {
7499
});
75100
}
76101

102+
async function handleDeviceCodeAuth(startUrl?: string, region?: string) {
103+
setLoginState("loading");
104+
setError(null);
105+
setLoginUrl(null);
106+
setCopyToClipboardText("Copy to clipboard");
107+
await Auth.cancelPkceAuthorization().catch((err) => {
108+
console.error(err);
109+
});
110+
const init = await Auth.builderIdStartDeviceAuthorization({
111+
startUrl,
112+
region,
113+
}).catch((err) => {
114+
setLoginState("not started");
115+
setLoginCode(null);
116+
setError(err.message);
117+
console.error(err);
118+
});
119+
120+
if (!init) return;
121+
122+
setLoginCode(init.code);
123+
setLoginUrl(init.url);
124+
125+
await Auth.builderIdPollCreateToken(init)
126+
.then(() => {
127+
setLoginState("logged in");
128+
Internal.sendWindowFocusRequest({});
129+
refreshAuth();
130+
next();
131+
})
132+
.catch((err) => {
133+
setLoginState("not started");
134+
setLoginCode(null);
135+
setError(err.message);
136+
console.error(err);
137+
});
138+
}
139+
77140
useEffect(() => {
78141
setLoginState(auth.authed ? "logged in" : "not started");
79142
}, [auth]);
@@ -103,16 +166,31 @@ export default function LoginModal({ next }: { next: () => void }) {
103166
</div>
104167
)}
105168
</div>
169+
106170
{error && (
107-
<div className="w-full bg-red-200 border border-red-600 rounded py-1 px-1">
171+
<div className="flex flex-col items-center gap-2 w-full bg-red-200 border border-red-600 rounded py-2 px-2">
108172
<p className="text-black dark:text-white font-semibold text-center">
109173
Failed to login
110174
</p>
111175
<p className="text-black dark:text-white text-center">{error}</p>
176+
{loginMethod === "pkce" && loginState === "loading" && (
177+
<Button
178+
variant="ghost"
179+
className="self-center mx-auto text-black hover:bg-white/40"
180+
onClick={() => {
181+
setLoginMethod("deviceCode");
182+
setLoginState("not started");
183+
setError(null);
184+
}}
185+
>
186+
Login with Device Code
187+
</Button>
188+
)}
112189
</div>
113190
)}
114-
<div className="flex flex-col gap-4 text-white text-sm">
115-
{loginState === "loading" ? (
191+
192+
<div className="flex flex-col items-center gap-4 text-white text-sm">
193+
{loginState === "loading" && loginMethod === "pkce" ? (
116194
<>
117195
<p className="text-center w-80">
118196
Waiting for authentication in the browser to complete...
@@ -127,6 +205,37 @@ export default function LoginModal({ next }: { next: () => void }) {
127205
Back
128206
</Button>
129207
</>
208+
) : loginState === "loading" &&
209+
loginMethod === "deviceCode" &&
210+
loginCode &&
211+
loginUrl ? (
212+
<>
213+
<p className="text-center w-80">
214+
Confirm code <span className="font-bold">{loginCode}</span> in the
215+
login page at the following link:
216+
</p>
217+
<p className="text-center">{loginUrl}</p>
218+
<Button
219+
variant="ghost"
220+
className="h-auto p-1 px-2 hover:bg-white/20 hover:text-white"
221+
onClick={() => {
222+
navigator.clipboard.writeText(loginUrl);
223+
setCopyToClipboardText("Copied!");
224+
}}
225+
>
226+
{copyToClipboardText}
227+
</Button>
228+
<Button
229+
variant="glass"
230+
className="self-center w-32"
231+
onClick={() => {
232+
setLoginState("not started");
233+
setLoginCode(null);
234+
}}
235+
>
236+
Back
237+
</Button>
238+
</>
130239
) : (
131240
<Tab
132241
tab={tab}

crates/fig_desktop_api/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ whoami.workspace = true
4141

4242
[dev-dependencies]
4343
reqwest.workspace = true
44+
tracing-subscriber.workspace = true
4445

4546
[target.'cfg(target_os="macos")'.dependencies]
4647
macos-utils = { path = "../macos-utils" }

crates/fig_desktop_api/src/handler.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ where
182182
ApplicationUpdateStatusRequest,
183183
AuthBuilderIdPollCreateTokenRequest,
184184
AuthBuilderIdStartDeviceAuthorizationRequest,
185+
AuthCancelPkceAuthorizationRequest,
185186
AuthFinishPkceAuthorizationRequest,
186187
AuthStartPkceAuthorizationRequest,
187188
AuthStatusRequest,
@@ -285,6 +286,7 @@ where
285286
event_handler.user_logged_in_callback(ctx).await;
286287
result
287288
},
289+
AuthCancelPkceAuthorizationRequest(request) => auth::cancel_pkce_authorization(request).await,
288290
AuthBuilderIdStartDeviceAuthorizationRequest(request) => {
289291
auth::builder_id_start_device_authorization(request, &ctx).await
290292
},

crates/fig_desktop_api/src/init_script.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::env::{
55
};
66

77
use camino::Utf8PathBuf;
8+
use fig_os_shim::Context;
89
use fig_util::directories::midway_cookie_path;
910
#[cfg(target_os = "linux")]
1011
use fig_util::system_info::linux::{
@@ -66,7 +67,8 @@ pub struct Constants {
6667

6768
impl Constants {
6869
fn new(support_api_proto: bool) -> Self {
69-
let themes_folder = directories::themes_dir()
70+
let ctx = Context::new();
71+
let themes_folder = directories::themes_dir(&ctx)
7072
.ok()
7173
.and_then(|dir| Utf8PathBuf::try_from(dir).ok());
7274

crates/fig_desktop_api/src/requests/auth.rs

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ use fig_proto::fig::{
1919
AuthBuilderIdPollCreateTokenResponse,
2020
AuthBuilderIdStartDeviceAuthorizationRequest,
2121
AuthBuilderIdStartDeviceAuthorizationResponse,
22+
AuthCancelPkceAuthorizationRequest,
23+
AuthCancelPkceAuthorizationResponse,
2224
AuthFinishPkceAuthorizationRequest,
2325
AuthFinishPkceAuthorizationResponse,
2426
AuthStartPkceAuthorizationRequest,
@@ -33,7 +35,10 @@ use tokio::sync::mpsc::{
3335
Sender,
3436
channel,
3537
};
36-
use tracing::error;
38+
use tracing::{
39+
debug,
40+
error,
41+
};
3742

3843
use super::RequestResult;
3944
use crate::kv::KVStore;
@@ -89,6 +94,9 @@ struct PkceState<T> {
8994

9095
/// [Receiver] for completed PKCE authorizations. Created per authorization attempt.
9196
finished_rx: Mutex<Receiver<(String, Result<(), fig_auth::Error>)>>,
97+
98+
/// [Sender] for cancelling any potentially ongoing PKCE authorizations.
99+
cancel_tx: Mutex<Sender<()>>,
92100
}
93101

94102
impl<T: PkceClient + Send + Sync + 'static> PkceState<T> {
@@ -97,26 +105,37 @@ impl<T: PkceClient + Send + Sync + 'static> PkceState<T> {
97105
fn new() -> Arc<Self> {
98106
let (new_tx, mut new_rx) = channel::<(String, PkceRegistration, T, Option<SecretStore>)>(1);
99107
let (finished_tx, finished_rx) = channel(1);
108+
let (cancel_tx, mut cancel_rx) = channel(1);
100109

101110
let new_tx_clone = new_tx.clone();
102111
let pkce_state = Arc::new(PkceState {
103112
request_id: Mutex::new("".into()),
104113
new_tx,
105114
finished_tx: Mutex::new(finished_tx),
106115
finished_rx: Mutex::new(finished_rx),
116+
cancel_tx: Mutex::new(cancel_tx),
107117
});
108118
let pkce_state_clone = Arc::clone(&pkce_state);
109119
tokio::spawn(async move {
110120
let registration_tx = new_tx_clone;
111121
let pkce_state = pkce_state_clone;
112122
while let Some((auth_request_id, registration, client, secret_store)) = new_rx.recv().await {
123+
while cancel_rx.try_recv().is_ok() {
124+
debug!("Ignoring buffered PKCE cancellation attempt");
125+
}
126+
113127
tokio::select! {
114128
// We received a new registration while waiting for the current one to complete,
115129
// so resend it back around the loop.
116130
Some(new_registration) = new_rx.recv() => {
117-
if let Err(err) = registration_tx.send(new_registration).await {
118-
error!(?err, "Error attempting to reprocess registration");
119-
}
131+
debug!(
132+
"Received a new registration for request_id: {} while already processing: {}",
133+
new_registration.0,
134+
auth_request_id
135+
);
136+
if let Err(err) = registration_tx.send(new_registration).await {
137+
error!(?err, "Error attempting to reprocess registration");
138+
}
120139
}
121140
// Registration successfully finished, send back the result.
122141
result = registration.finish(&client, secret_store.as_ref()) => {
@@ -130,6 +149,18 @@ impl<T: PkceClient + Send + Sync + 'static> PkceState<T> {
130149
error!(?err, "unknown error occurred finishing PKCE registration");
131150
}
132151
}
152+
_ = cancel_rx.recv() => {
153+
debug!("Cancelling current registration for request_id: {}", auth_request_id);
154+
if let Err(err) = pkce_state
155+
.finished_tx
156+
.lock()
157+
.await
158+
.send((auth_request_id, Err(fig_auth::Error::OAuthCustomError("cancelled".into()))))
159+
.await
160+
{
161+
error!(?err, "unknown error occurred cancelling PKCE registration");
162+
}
163+
}
133164
}
134165
}
135166
});
@@ -176,6 +207,11 @@ impl<T: PkceClient + Send + Sync + 'static> PkceState<T> {
176207
None => Err(PkceError::RegistrationCancelled),
177208
}
178209
}
210+
211+
/// Cancel an ongoing PKCE registration, if one is present.
212+
async fn cancel_registration(&self) {
213+
self.cancel_tx.lock().await.try_send(()).ok();
214+
}
179215
}
180216

181217
pub async fn status(_request: AuthStatusRequest) -> RequestResult {
@@ -236,6 +272,11 @@ pub async fn finish_pkce_authorization(
236272
Ok(ServerOriginatedSubMessage::AuthFinishPkceAuthorizationResponse(AuthFinishPkceAuthorizationResponse {}).into())
237273
}
238274

275+
pub async fn cancel_pkce_authorization(_: AuthCancelPkceAuthorizationRequest) -> RequestResult {
276+
PKCE_REGISTRATION.cancel_registration().await;
277+
Ok(ServerOriginatedSubMessage::AuthCancelPkceAuthorizationResponse(AuthCancelPkceAuthorizationResponse {}).into())
278+
}
279+
239280
pub async fn builder_id_start_device_authorization(
240281
AuthBuilderIdStartDeviceAuthorizationRequest { start_url, region }: AuthBuilderIdStartDeviceAuthorizationRequest,
241282
ctx: &impl KVStore,
@@ -433,4 +474,40 @@ mod tests {
433474
let result_one = pkce_state.finish_registration(&id_one).await;
434475
assert!(matches!(result_one, Err(PkceError::RegistrationCancelled)));
435476
}
477+
478+
#[tokio::test]
479+
async fn test_pkce_state_is_cancellable() {
480+
tracing_subscriber::fmt::init();
481+
482+
let pkce_state = PkceState::new();
483+
484+
// Cancelling before we can finish the registration.
485+
{
486+
pkce_state.cancel_registration().await;
487+
pkce_state.cancel_registration().await; // works multiple times
488+
489+
let client = TestPkceClient {};
490+
let registration = PkceRegistration::register(&client, test_region(), test_issuer_url(), None)
491+
.await
492+
.unwrap();
493+
let (uri, state) = (registration.redirect_uri.clone(), registration.state.clone());
494+
let request_id = pkce_state.start_registration(registration, client, None).await;
495+
send_test_auth_code(uri, state).await.unwrap();
496+
assert!(pkce_state.finish_registration(&request_id).await.is_ok());
497+
}
498+
499+
// Cancelling closes the server
500+
{
501+
let client = TestPkceClient {};
502+
let registration = PkceRegistration::register(&client, test_region(), test_issuer_url(), None)
503+
.await
504+
.unwrap();
505+
let (uri, state) = (registration.redirect_uri.clone(), registration.state.clone());
506+
let _ = pkce_state.start_registration(registration, client, None).await;
507+
// Give time for the HTTP server to be hosted
508+
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
509+
pkce_state.cancel_registration().await;
510+
assert!(send_test_auth_code(uri, state).await.is_err());
511+
}
512+
}
436513
}

crates/fig_util/src/directories.rs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -269,11 +269,7 @@ pub fn host_sockets_dir() -> Result<PathBuf> {
269269
}
270270

271271
/// The path to all of the themes
272-
pub fn themes_dir() -> Result<PathBuf> {
273-
Ok(resources_path()?.join("themes"))
274-
}
275-
276-
pub fn themes_dir_ctx(ctx: &Context) -> Result<PathBuf> {
272+
pub fn themes_dir(ctx: &Context) -> Result<PathBuf> {
277273
Ok(resources_path_ctx(ctx)?.join("themes"))
278274
}
279275

0 commit comments

Comments
 (0)