Files
windmill/backend/windmill-api/src/oidc.rs
2024-01-18 12:18:40 +01:00

306 lines
11 KiB
Rust

/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2023
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use std::process::Command;
use anyhow;
#[cfg(feature = "enterprise")]
use openidconnect::{
core::{
CoreClaimName, CoreJsonWebKeySet, CoreJwsSigningAlgorithm, CoreProviderMetadata,
CoreResponseType, CoreRsaPrivateSigningKey, CoreSubjectIdentifierType,
},
AdditionalClaims, AuthUrl, EmptyAdditionalProviderMetadata, IssuerUrl, JsonWebKeyId,
JsonWebKeySetUrl, ResponseTypes,
};
#[cfg(feature = "enterprise")]
impl AdditionalClaims for JobClaim {}
use crate::db::DB;
use axum::extract::Path;
use axum::routing::{get, post};
use axum::Extension;
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
#[cfg(feature = "enterprise")]
pub fn global_service() -> Router {
Router::new()
.route(
"/.well-known/openid-configuration",
get(openid_configuration),
)
.route("/jwks", get(jwks))
}
#[cfg(not(feature = "enterprise"))]
pub fn global_service() -> Router {
Router::new()
}
#[cfg(not(feature = "enterprise"))]
pub fn workspaced_service() -> Router {
Router::new()
}
#[cfg(feature = "enterprise")]
pub fn workspaced_service() -> Router {
Router::new().route("/token/:audience", post(gen_token))
}
#[derive(Debug, Clone, serde::Serialize)]
struct Keys {
private_key: String,
}
async fn gen_pems(db: &DB) -> anyhow::Result<Keys> {
let private_key_cmd = Command::new("openssl")
.arg("genrsa")
.arg("--traditional")
.arg("2048")
.output()
.expect("failed to execute process");
let private_key = String::from_utf8(private_key_cmd.stdout).unwrap();
tracing::debug!("Generated private key: {}", private_key);
let keys = Keys { private_key };
sqlx::query!(
"INSERT INTO global_settings (name, value) VALUES ('rsa_keys', $1)",
serde_json::to_value(&keys).unwrap()
)
.execute(db)
.await?;
Ok(keys)
}
#[cfg(feature = "enterprise")]
async fn get_private_key(db: &DB) -> anyhow::Result<String> {
let key = sqlx::query_scalar!(
"SELECT value->>'private_key' FROM global_settings WHERE name = 'rsa_keys'",
)
.fetch_optional(db)
.await?
.flatten();
if let Some(key) = key {
return Ok(key);
} else {
let keys = gen_pems(db).await?;
return Ok(keys.private_key);
}
}
#[cfg(feature = "enterprise")]
pub async fn jwks(
Extension(db): Extension<DB>,
) -> windmill_common::error::JsonResult<CoreJsonWebKeySet> {
use openidconnect::PrivateSigningKey;
let private_key = get_private_key(&db).await?;
let jwks = CoreJsonWebKeySet::new(vec![CoreRsaPrivateSigningKey::from_pem(
&private_key,
Some(JsonWebKeyId::new("windmill".to_string())),
)
.map_err(|e| anyhow::anyhow!("Failed to parse PEM: {}", e))?
.as_verification_key()]);
Ok(Json(jwks))
}
#[cfg(feature = "enterprise")]
pub async fn openid_configuration() -> windmill_common::error::JsonResult<CoreProviderMetadata> {
use windmill_common::BASE_URL;
let base_url = BASE_URL.read().await.clone();
return get_provider_metadata(base_url)
.map(Json)
.map_err(|e| e.into());
}
#[cfg(feature = "enterprise")]
pub fn get_provider_metadata(base_url: String) -> anyhow::Result<CoreProviderMetadata> {
let provider_metadata = CoreProviderMetadata::new(
IssuerUrl::new(format!("{base_url}/api/oidc/"))?,
AuthUrl::new(format!("{base_url}/api/oidc/"))?,
JsonWebKeySetUrl::new(format!("{base_url}/api/oidc/jwks"))?,
vec![
// Optional: support the implicit flow.
ResponseTypes::new(vec![CoreResponseType::Token, CoreResponseType::IdToken]), // Other flows including hybrid flows may also be specified here.
],
vec![CoreSubjectIdentifierType::Public],
vec![CoreJwsSigningAlgorithm::RsaSsaPssSha256],
EmptyAdditionalProviderMetadata {},
)
// Recommended: specify the supported ID token claims.
.set_claims_supported(Some(vec![
// Providers may also define an enum instead of using CoreClaimName.
CoreClaimName::new("sub".to_string()),
CoreClaimName::new("aud".to_string()),
CoreClaimName::new("email".to_string()),
CoreClaimName::new("email_verified".to_string()),
CoreClaimName::new("exp".to_string()),
CoreClaimName::new("iat".to_string()),
CoreClaimName::new("iss".to_string()),
CoreClaimName::new("job_id".to_string()),
CoreClaimName::new("path".to_string()),
CoreClaimName::new("flow_path".to_string()),
CoreClaimName::new("groups".to_string()),
CoreClaimName::new("username".to_string()),
CoreClaimName::new("workspace".to_string()),
]));
return Ok(provider_metadata);
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
struct JobClaim {
job_id: String,
path: Option<String>,
flow_path: Option<String>,
groups: Vec<String>,
username: String,
email: String,
workspace: String,
}
use crate::db::ApiAuthed;
use crate::users::Tokened;
#[cfg(feature = "enterprise")]
pub async fn gen_token(
authed: ApiAuthed,
token: Tokened,
Extension(db): Extension<DB>,
Path((w_id, audience)): Path<(String, String)>,
) -> windmill_common::error::Result<String> {
use chrono::{Duration, Utc};
use openidconnect::{
core::{CoreGenderClaim, CoreJsonWebKeyType, CoreJweContentEncryptionAlgorithm},
Audience, EndUserEmail, IdToken, IdTokenClaims, StandardClaims, SubjectIdentifier,
};
use windmill_queue::get_queued_job;
use crate::users::get_groups_for_user;
let private_key = get_private_key(&db).await?;
let username = authed.username;
let email = authed.email;
let job_id = {
let job = sqlx::query_scalar!("SELECT job FROM token WHERE token = $1", token.token)
.fetch_optional(&db)
.await?
.flatten();
if job.is_none() {
return Err(anyhow::anyhow!("Token not found").into());
} else {
job.unwrap()
}
};
let mut tx = db.begin().await?;
let job = get_queued_job(job_id, &w_id, &mut tx).await?;
tx.commit().await?;
let job = job.ok_or_else(|| anyhow::anyhow!("Queued job {} not found", job_id))?;
let issue_url = format!("{}/api/oidc/", crate::BASE_URL.read().await.clone());
let flow_path = if let Some(uuid) = job.parent_job {
sqlx::query_scalar!("SELECT script_path FROM queue WHERE id = $1", uuid)
.fetch_optional(&db)
.await?
.flatten()
} else {
None
};
let groups = get_groups_for_user(&w_id, &username, &email, &db)
.await
.ok()
.unwrap_or_default();
let id_token = IdToken::<
JobClaim,
CoreGenderClaim,
CoreJweContentEncryptionAlgorithm,
CoreJwsSigningAlgorithm,
CoreJsonWebKeyType,
>::new(
IdTokenClaims::<JobClaim, CoreGenderClaim>::new(
// Specify the issuer URL for the OpenID Connect Provider.
IssuerUrl::new(issue_url)
.map_err(|e| anyhow::anyhow!("Failed to generate IssueUrl: {}", e))?,
// The audience is usually a single entry with the client ID of the client for whom
// the ID token is intended. This is a required claim.
vec![Audience::new(audience)],
// The ID token expiration is usually much shorter than that of the access or refresh
// tokens issued to clients.
Utc::now() + Duration::hours(48),
// The issue time is usually the current time.
Utc::now(),
// Set the standard claims defined by the OpenID Connect Core spec.
StandardClaims::new(
// Stable subject identifiers are recommended in place of e-mail addresses or other
// potentially unstable identifiers. This is the only required claim.
SubjectIdentifier::new(format!(
"{}::{}::{}::{}",
email,
job.script_path
.clone()
.unwrap_or_else(|| "no_path".to_string()),
flow_path.clone().unwrap_or_else(|| "no_flow".to_string()),
w_id
)),
)
// Optional: specify the user's e-mail address. This should only be provided if the
// client has been granted the 'profile' or 'email' scopes.
.set_email(Some(EndUserEmail::new(job.email.clone())))
// Optional: specify whether the provider has verified the user's e-mail address.
.set_email_verified(Some(true)),
// OpenID Connect Providers may supply custom claims by providing a struct that
// implements the AdditionalClaims trait. This requires manually using the
// generic IdTokenClaims struct rather than the CoreIdTokenClaims type alias,
// however.
JobClaim {
job_id: job_id.to_string(),
path: job.script_path,
flow_path,
username: job.created_by,
email: job.email,
workspace: job.workspace_id,
groups,
},
),
// The private key used for signing the ID token. For confidential clients (those able
// to maintain a client secret), a CoreHmacKey can also be used, in conjunction
// with one of the CoreJwsSigningAlgorithm::HmacSha* signing algorithms. When using an
// HMAC-based signing algorithm, the UTF-8 representation of the client secret should
// be used as the HMAC key.
&CoreRsaPrivateSigningKey::from_pem(
&private_key,
Some(JsonWebKeyId::new("windmill".to_string())),
)
.map_err(|e| anyhow::anyhow!("Invalid private key: {}", e))?,
// Uses the RS256 signature algorithm. This crate supports any RS*, PS*, or HS*
// signature algorithm.
CoreJwsSigningAlgorithm::RsaSsaPkcs1V15Sha256,
// When returning the ID token alongside an access token (e.g., in the Authorization Code
// flow), it is recommended to pass the access token here to set the `at_hash` claim
// automatically.
None,
// When returning the ID token alongside an authorization code (e.g., in the implicit
// flow), it is recommended to pass the authorization code here to set the `c_hash` claim
// automatically.
None,
)
.map_err(|e| anyhow::anyhow!("Failed to generate token: {}", e))?;
Ok(id_token.to_string())
}