golem_cli/
auth.rs

1// Copyright 2024-2025 Golem Cloud
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use crate::cloud::{
16    AccountId, AuthSecret, CloudAuthenticationConfig, CloudAuthenticationConfigData,
17};
18use crate::config::{Config, Profile, ProfileName};
19use crate::error::service::AnyhowMapServiceError;
20use crate::log::LogColorize;
21use anyhow::{anyhow, bail, Context};
22use colored::Colorize;
23use golem_cloud_client::api::{LoginClient, LoginClientLive, LoginOauth2WebFlowPollError};
24use golem_cloud_client::model::{Token, TokenSecret, UnsafeToken, WebFlowAuthorizeUrlResponse};
25use golem_cloud_client::Security;
26use indoc::printdoc;
27use std::path::Path;
28use tracing::info;
29use uuid::Uuid;
30
31impl From<&CloudAuthenticationConfig> for CloudAuthentication {
32    fn from(val: &CloudAuthenticationConfig) -> Self {
33        CloudAuthentication(UnsafeToken {
34            data: Token {
35                id: val.data.id,
36                account_id: val.data.account_id.to_string(),
37                created_at: val.data.created_at,
38                expires_at: val.data.expires_at,
39            },
40            secret: TokenSecret {
41                value: val.secret.0,
42            },
43        })
44    }
45}
46
47pub fn unsafe_token_to_auth_config(value: &UnsafeToken) -> CloudAuthenticationConfig {
48    CloudAuthenticationConfig {
49        data: CloudAuthenticationConfigData {
50            id: value.data.id,
51            account_id: value.data.account_id.to_string(),
52            created_at: value.data.created_at,
53            expires_at: value.data.expires_at,
54        },
55        secret: AuthSecret(value.secret.value),
56    }
57}
58
59pub struct Auth {
60    login_client: LoginClientLive,
61}
62
63impl Auth {
64    pub fn new(login_client: LoginClientLive) -> Self {
65        Self { login_client }
66    }
67
68    pub async fn authenticate(
69        &self,
70        token_override: Option<Uuid>,
71        auth_config: Option<&CloudAuthenticationConfig>,
72        config_dir: &Path,
73        profile_name: &ProfileName,
74    ) -> anyhow::Result<CloudAuthentication> {
75        if let Some(token_override) = token_override {
76            let secret = TokenSecret {
77                value: token_override,
78            };
79            let data = self.token_details(secret.clone()).await?;
80
81            Ok(CloudAuthentication(UnsafeToken { data, secret }))
82        } else {
83            self.profile_authentication(auth_config, config_dir, profile_name)
84                .await
85        }
86    }
87
88    fn save_auth(
89        &self,
90        token: &UnsafeToken,
91        profile_name: &ProfileName,
92        config_dir: &Path,
93    ) -> anyhow::Result<()> {
94        let profile = Config::get_profile(config_dir, profile_name)?.ok_or(anyhow!(
95            "Can't find profile {} in config",
96            profile_name.0.log_color_highlight()
97        ))?;
98
99        match profile.profile {
100            Profile::Golem(_) => Err(anyhow!(
101                "Profile {} is an OSS profile. Cloud profile expected.",
102                profile_name.0.log_color_highlight()
103            )),
104            Profile::GolemCloud(mut profile) => {
105                profile.auth = Some(unsafe_token_to_auth_config(token));
106                Config::set_profile(
107                    profile_name.clone(),
108                    Profile::GolemCloud(profile),
109                    config_dir,
110                )
111                .with_context(|| "Failed to save auth token")?;
112
113                Ok(())
114            }
115        }
116    }
117
118    async fn oauth2(
119        &self,
120        profile_name: &ProfileName,
121        config_dir: &Path,
122    ) -> anyhow::Result<CloudAuthentication> {
123        let data = self.start_oauth2().await?;
124        inform_user(&data);
125        let token = self.complete_oauth2(data.state).await?;
126        self.save_auth(&token, profile_name, config_dir)?;
127        Ok(CloudAuthentication(token))
128    }
129
130    async fn profile_authentication(
131        &self,
132        auth_config: Option<&CloudAuthenticationConfig>,
133        config_dir: &Path,
134        profile_name: &ProfileName,
135    ) -> anyhow::Result<CloudAuthentication> {
136        if let Some(data) = auth_config {
137            Ok(data.into())
138        } else {
139            self.oauth2(profile_name, config_dir).await
140        }
141    }
142
143    async fn token_details(&self, token_secret: TokenSecret) -> anyhow::Result<Token> {
144        info!("Getting token info");
145        let mut context = self.login_client.context.clone();
146        context.security_token = Security::Bearer(token_secret.value.to_string());
147
148        let client = LoginClientLive { context };
149
150        client.current_login_token().await.map_service_error()
151    }
152
153    async fn start_oauth2(&self) -> anyhow::Result<WebFlowAuthorizeUrlResponse> {
154        info!("Start OAuth2 workflow");
155        self.login_client
156            .oauth_2_web_flow_start("github", Some("https://golem.cloud"))
157            .await
158            .map_service_error()
159    }
160
161    async fn complete_oauth2(&self, state: String) -> anyhow::Result<UnsafeToken> {
162        use tokio::time::{sleep, Duration};
163
164        info!("Complete OAuth2 workflow");
165        let mut attempts = 0;
166        let max_attempts = 60;
167        let delay = Duration::from_secs(1);
168
169        loop {
170            let status = self.login_client.oauth_2_web_flow_poll(&state).await;
171            match status {
172                Ok(token) => return Ok(token),
173                Err(err) => match err {
174                    golem_cloud_client::Error::Item(LoginOauth2WebFlowPollError::Error202(_)) => {
175                        attempts += 1;
176                        if attempts >= max_attempts {
177                            bail!("OAuth2 workflow timeout")
178                        }
179
180                        sleep(delay).await;
181                    }
182                    _ => return Err(err).map_service_error(),
183                },
184            }
185        }
186    }
187}
188
189fn inform_user(data: &WebFlowAuthorizeUrlResponse) {
190    let url = &data.url.underline();
191
192    printdoc! {
193        "
194        ┌────────────────────────────────────────┐
195        │       Authenticate with GitHub         │
196        │                                        │
197        │  Visit the following URL in a browser  │
198        │                                        │
199        └────────────────────────────────────────┘
200        {url}
201        ──────────────────────────────────────────
202        "
203    }
204
205    println!("Waiting for authentication...");
206}
207
208#[derive(Clone, PartialEq, Debug)]
209pub struct CloudAuthentication(pub UnsafeToken);
210
211impl CloudAuthentication {
212    pub fn header(&self) -> String {
213        token_header(&self.0.secret)
214    }
215
216    pub fn account_id(&self) -> AccountId {
217        AccountId(self.0.data.account_id.clone())
218    }
219}
220
221pub fn token_header(secret: &TokenSecret) -> String {
222    format!("bearer {}", secret.value)
223}