Skip to content

Commit

Permalink
Replace Google SSO with generic OIDC functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
raffomania committed Sep 4, 2024
1 parent 045d6df commit 660966d
Show file tree
Hide file tree
Showing 9 changed files with 204 additions and 115 deletions.
14 changes: 11 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,14 @@ DEMO_MODE=false
ADMIN_USERNAME=
ADMIN_PASSWORD=

# Used for Google's Simple Sign On (SSO), to obtain the value, go to google developers console (https://console.cloud.google.com/apis/dashboard) and create an OAuth 2.0 Client credentials
OAUTH_GOOGLE_CLIENT_ID=
OAUTH_GOOGLE_CLIENT_SECRET=
# Used for Single Sign On (SSO).
OAUTH_CLIENT_ID=
OAUTH_CLIENT_SECRET=
# Only used for spinning up a rauthy container in
# local dev environments
RAUTHY_PORT=55434
OAUTH_ISSUER_URL=http://localhost:${RAUTHY_PORT}/auth/v1
OAUTH_ISSUER_NAME=Rauthy

TLS_KEY=development_cert/localhost.key
TLS_CERT=development_cert/localhost.crt
Expand All @@ -23,3 +28,6 @@ SQLX_OFFLINE=true
DATABASE_NAME_TEST=linkblocks_test
DATABASE_PORT_TEST=55433
DATABASE_URL_TEST=postgres://postgres@localhost:${DATABASE_PORT_TEST}/${DATABASE_NAME_TEST}
# Only used for spinning up a rauthy container in
# local dev environments
RAUTHY_PORT=55434
31 changes: 29 additions & 2 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,33 @@ start-database:
sleep 2
done

start-rauthy:
#!/usr/bin/env bash
set -euxo pipefail
# TODO: extract helpers for repetitive podman tasks.
if podman ps --format "{{{{.Names}}" | grep -wq linkblocks_rauthy; then
echo "Rauthy is running."
exit
fi

if ! podman inspect linkblocks_rauthy &> /dev/null; then
podman create \
--replace --name linkblocks_rauthy \
-e COOKIE_MODE=danger-insecure \
-e PUB_URL=localhost:${RAUTHY_PORT} \
-e LOG_LEVEL=info \
-e BOOTSTRAP_ADMIN_PASSWORD_PLAIN="test" \
-e DATABASE_URL=sqlite:data/rauthy.db \
-p ${RAUTHY_PORT}:8080 \
ghcr.io/sebadob/rauthy:0.25.0-lite
fi

podman start linkblocks_rauthy

stop-rauthy:
podman stop linkblocks_rauthy

stop-database:
podman stop linkblocks_postgres

Expand Down Expand Up @@ -87,8 +114,8 @@ ci-dev: start-database migrate-database start-test-database && lint format test
cargo build --release

lint:
cargo clippy -- -D warnings
lint *args:
cargo clippy {{args}} -- -D warnings

format: format-templates
cargo fmt --all -- --check
Expand Down
44 changes: 31 additions & 13 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use crate::{
db,
forms::users::CreateUser,
oidc,
server::{self, AppState},
server::{self, AppState, OauthState},
};

#[derive(Parser)]
Expand All @@ -30,6 +30,9 @@ struct SharedConfig {
database_url: String,
}

// Since this enum is only ever constructed once,
// we only waste very little memory due to large enum variants.
#[allow(clippy::large_enum_variant)]
#[derive(Parser)]
enum Command {
/// Migrate the database, then start the server
Expand All @@ -53,9 +56,13 @@ enum Command {
#[clap(long, env, default_value = "false")]
demo_mode: bool,
#[clap(long, env)]
oauth_google_client_id: Option<String>,
oauth_client_id: Option<String>,
#[clap(long, env)]
oauth_google_client_secret: Option<String>,
oauth_client_secret: Option<String>,
#[clap(long, env)]
oauth_issuer_url: Option<String>,
#[clap(long, env)]
oauth_issuer_name: Option<String>,
},
Db {
#[clap(subcommand)]
Expand Down Expand Up @@ -126,8 +133,10 @@ pub async fn run() -> Result<()> {
tls_key,
base_url,
demo_mode,
oauth_google_client_id,
oauth_google_client_secret,
oauth_client_id,
oauth_client_secret,
oauth_issuer_url,
oauth_issuer_name,
} => {
let pool = db::pool(&cli.config.database_url).await?;

Expand All @@ -142,21 +151,30 @@ pub async fn run() -> Result<()> {
tx.commit().await?;
}

let oauth_google_client = match (
oauth_google_client_id.as_deref(),
oauth_google_client_secret.as_deref(),
let oauth_state = match (
oauth_client_id,
oauth_client_secret,
oauth_issuer_url,
oauth_issuer_name,
) {
(Some(id), Some(secret)) => {
Some(oidc::client(base_url.clone(), id.to_string(), secret.to_string()).await?)
}
_ => None,
(Some(id), Some(secret), Some(url), Some(name)) => OauthState::Configured {
client: oidc::client(
base_url.clone(),
id.to_string(),
secret.to_string(),
url.to_string(),
)
.await?,
name,
},
_ => OauthState::NotConfigured,
};

let app = server::app(AppState {
pool,
base_url: base_url.clone(),
demo_mode,
oauth_google_client,
oauth_state,
})
.await?;
server::start(listen_address, app, tls_cert, tls_key).await?;
Expand Down
21 changes: 12 additions & 9 deletions src/oidc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,35 @@ use serde::{Deserialize, Serialize};

use openidconnect::core::{CoreClient, CoreProviderMetadata};
use openidconnect::reqwest::async_http_client;
use openidconnect::{ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, RedirectUrl};
use openidconnect::{
ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, PkceCodeVerifier, RedirectUrl,
};

#[derive(Serialize, Deserialize)]
pub struct Session {
pub nonce: Nonce,
pub csrf_token: CsrfToken,
pub pkce_verifier: PkceCodeVerifier,
}

pub async fn client(
base_url: String,
oauth_google_client_id: String,
oauth_google_client_secret: String,
oauth_client_id: String,
oauth_client_secret: String,
oauth_issuer_url: String,
) -> anyhow::Result<CoreClient> {
let client_id = ClientId::new(oauth_google_client_id);
let client_secret = ClientSecret::new(oauth_google_client_secret);
let issuer_url = IssuerUrl::new("https://accounts.google.com".to_string())
.context("failed to reach issuer")?;
let client_id = ClientId::new(oauth_client_id);
let client_secret = ClientSecret::new(oauth_client_secret);
let issuer_url = IssuerUrl::new(oauth_issuer_url).context("failed to reach issuer")?;

let provider_metadata = CoreProviderMetadata::discover_async(issuer_url, async_http_client)
.await
.context("failed to discover provider")?;
// Set up the config for the Google OAuth2 process.
// Set up the config for the OAuth2 process.
let client =
CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret))
.set_redirect_uri(
RedirectUrl::new(base_url + "/login_google_handler")
RedirectUrl::new(base_url + "/login_oauth_handler")
.context("Invalid redirect URL")?,
);

Expand Down
51 changes: 20 additions & 31 deletions src/routes/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,18 @@ use crate::{
};
use serde::Deserialize;

use openidconnect::core::{CoreIdTokenVerifier, CoreResponseType, CoreRevocableToken};
use openidconnect::reqwest::async_http_client;
use openidconnect::{
AuthenticationFlow, AuthorizationCode, CsrfToken, Nonce, OAuth2TokenResponse, Scope,
core::{CoreIdTokenVerifier, CoreResponseType},
PkceCodeChallenge,
};
use openidconnect::{AuthenticationFlow, AuthorizationCode, CsrfToken, Nonce, Scope};

pub fn router() -> Router<AppState> {
Router::new()
.route("/login", get(get_login).post(post_login))
.route("/login_google_handler", get(get_login_google_handler))
.route("/login_google", get(get_login_google))
.route("/login_oauth_handler", get(get_login_oauth_handler))
.route("/login_oauth", get(get_login_oauth))
.route("/login_demo", post(post_login_demo))
.route("/logout", post(logout))
.route("/profile", get(get_profile))
Expand All @@ -43,10 +44,7 @@ async fn post_login(
QsForm(input): QsForm<Login>,
) -> ResponseResult<Response> {
if let Err(errors) = input.validate() {
return Ok(
login::Template::new(errors, input, state.oauth_google_client.is_some())
.into_response(),
);
return Ok(login::Template::new(errors, input, state.oauth_state).into_response());
};

let logged_in = authentication::login(&mut tx, session, &input.credentials).await;
Expand All @@ -56,24 +54,23 @@ async fn post_login(
garde::Path::new("root"),
garde::Error::new("Username or password not correct"),
);
return Ok(
login::Template::new(errors, input, state.oauth_google_client.is_some())
.into_response(),
);
return Ok(login::Template::new(errors, input, state.oauth_state).into_response());
}

let redirect_to = input.previous_uri.unwrap_or("/".to_string());

Ok(Redirect::to(&redirect_to).into_response())
}

async fn get_login_google(
async fn get_login_oauth(
State(state): State<AppState>,
session: Session,
) -> ResponseResult<Response> {
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
// Generate the authorization URL to which we'll redirect the user.
let (authorize_url, csrf_state, nonce) = state
.oauth_google_client
.oauth_state
.get_client()
.context("Google OAuth client not configured")?
.authorize_url(
AuthenticationFlow::<CoreResponseType>::AuthorizationCode,
Expand All @@ -82,6 +79,7 @@ async fn get_login_google(
)
.add_scope(Scope::new("email".to_string()))
.add_scope(Scope::new("profile".to_string()))
.set_pkce_challenge(pkce_challenge)
.url();

// TODO: Store the CSRF and none states in a way that is more secure than this, although the current method is already quire secure.
Expand All @@ -92,6 +90,7 @@ async fn get_login_google(
oidc::Session {
nonce,
csrf_token: csrf_state,
pkce_verifier,
},
)
.await
Expand All @@ -106,7 +105,7 @@ struct OAuthLoginQuery {
state: String,
}

async fn get_login_google_handler(
async fn get_login_oauth_handler(
session: Session,
Query(query): Query<OAuthLoginQuery>,
state: State<AppState>,
Expand All @@ -128,35 +127,25 @@ async fn get_login_google_handler(

let id_token_claims = {
let oauth_google_client = state
.oauth_google_client
.oauth_state
.clone()
.get_client()
.context("Google OAuth client not configured")?;
let token_response = oauth_google_client
.clone()
.exchange_code(code)
.set_pkce_verifier(oidc_session.pkce_verifier)
.request_async(async_http_client)
.await
.context("failed to get token response")?;
let id_token_verifier: CoreIdTokenVerifier = oauth_google_client.id_token_verifier();
let token_claims = token_response
token_response
.extra_fields()
.id_token()
.context("Server did not return an ID token")?
.claims(&id_token_verifier, &oidc_session.nonce)
.context("failed to get token claims")?
.clone();
let token_to_revoke: CoreRevocableToken = match token_response.refresh_token() {
Some(token) => token.into(),
None => token_response.access_token().into(),
};

oauth_google_client
.revoke_token(token_to_revoke)
.context("no revocation_uri configured")?
.request_async(async_http_client)
.await
.context("failed to revoke token")?;
token_claims
.clone()
};

let email = id_token_claims
Expand Down Expand Up @@ -213,7 +202,7 @@ async fn get_login(
previous_uri: query.previous_uri,
..Default::default()
},
state.oauth_google_client.is_some(),
state.oauth_state,
)
.into_response())
}
Expand Down
23 changes: 22 additions & 1 deletion src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,33 @@ use listenfd::ListenFd;
use openidconnect::core::CoreClient;
use tower_http::trace::TraceLayer;

// This enum is basically an Option with additional
// semantics. As such, it's expected that the
// `NotConfigured` variant will take up lots of space,
// Just like `None` would
#[allow(clippy::large_enum_variant)]
#[derive(Clone)]
pub enum OauthState {
NotConfigured,
Configured { client: CoreClient, name: String },
}

impl OauthState {
#[must_use]
pub fn get_client(self) -> Option<CoreClient> {
match self {
OauthState::NotConfigured => None,
OauthState::Configured { client, name: _ } => Some(client),
}
}
}

#[derive(Clone)]
pub struct AppState {
pub pool: sqlx::PgPool,
pub base_url: String,
pub demo_mode: bool,
pub oauth_google_client: Option<CoreClient>,
pub oauth_state: OauthState,
}

pub async fn app(state: AppState) -> anyhow::Result<Router> {
Expand Down
2 changes: 1 addition & 1 deletion src/tests/util/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ impl TestApp {
pool,
base_url: String::new(),
demo_mode: false,
oauth_google_client: None,
oauth_state: crate::server::OauthState::NotConfigured,
})
.await
.unwrap(),
Expand Down
Loading

0 comments on commit 660966d

Please sign in to comment.