use std::sync::Arc;
use axum::Router;
use axum::extract::{FromRef, State};
use axum::response::{Html, IntoResponse};
use axum::routing::{get, post};
use url::Url;
use crate::ctx::AuthCtx;
pub mod admin;
pub mod auth_params;
pub mod authorize;
pub mod binding;
pub mod consent;
pub mod discovery;
pub mod federation;
pub mod handlers;
pub mod introspect;
pub mod issuer_validation;
pub mod jwks;
pub mod revoke;
pub mod store;
pub mod token;
pub mod types;
pub mod upstreams_public;
pub mod userinfo;
pub use store::{
OidcClientStore, OidcCodeStore, OidcConsentStore, OidcRefreshStore, OidcSessionStore,
OidcUpstreamStateStore, OidcUpstreamStore,
};
pub use types::{
AuthorizationCode, ConsentGrant, OidcClient, OidcSession, RefreshToken, TokenAuthMethod,
UpstreamLoginState, UpstreamProvider,
};
#[cfg(feature = "backend-postgres")]
pub use store::{
PostgresOidcClientStore, PostgresOidcCodeStore, PostgresOidcConsentStore,
PostgresOidcRefreshStore, PostgresOidcSessionStore, PostgresOidcUpstreamStateStore,
PostgresOidcUpstreamStore,
};
#[cfg(feature = "backend-sqlite")]
pub use store::{
SqliteOidcClientStore, SqliteOidcCodeStore, SqliteOidcConsentStore, SqliteOidcRefreshStore,
SqliteOidcSessionStore, SqliteOidcUpstreamStateStore, SqliteOidcUpstreamStore,
};
#[derive(Clone)]
pub enum JwksSource {
#[cfg(feature = "backend-postgres")]
Postgres(sqlx::PgPool),
#[cfg(feature = "backend-sqlite")]
Sqlite(sqlx::SqlitePool),
Memory(Vec<serde_json::Value>),
}
#[derive(Clone)]
pub struct OidcProviderConfig {
pub issuer: String,
pub public_url: Url,
pub clients: Arc<dyn OidcClientStore>,
pub upstream: Arc<dyn OidcUpstreamStore>,
pub codes: Arc<dyn OidcCodeStore>,
pub refresh: Arc<dyn OidcRefreshStore>,
pub sessions: Arc<dyn OidcSessionStore>,
pub consents: Arc<dyn OidcConsentStore>,
pub upstream_states: Arc<dyn OidcUpstreamStateStore>,
pub jwks_source: JwksSource,
pub auto_provision: bool,
}
impl OidcProviderConfig {
#[allow(clippy::too_many_arguments)]
pub fn new(
issuer: impl Into<String>,
public_url: Url,
clients: Arc<dyn OidcClientStore>,
upstream: Arc<dyn OidcUpstreamStore>,
codes: Arc<dyn OidcCodeStore>,
refresh: Arc<dyn OidcRefreshStore>,
sessions: Arc<dyn OidcSessionStore>,
consents: Arc<dyn OidcConsentStore>,
upstream_states: Arc<dyn OidcUpstreamStateStore>,
) -> Self {
Self {
issuer: issuer.into(),
public_url,
clients,
upstream,
codes,
refresh,
sessions,
consents,
upstream_states,
jwks_source: JwksSource::Memory(Vec::new()),
auto_provision: true,
}
}
pub fn with_auto_provision(mut self, on: bool) -> Self {
self.auto_provision = on;
self
}
pub fn with_jwks_source(mut self, src: JwksSource) -> Self {
self.jwks_source = src;
self
}
}
pub fn upstream_callback_url(public_url: &url::Url, slug: &str) -> String {
let base = public_url.as_str().trim_end_matches('/');
format!("{base}/oidc/upstream/{slug}/callback")
}
pub async fn sync_upstream_to_registry(
registry: &crate::oidc::OidcRegistry,
row: &types::UpstreamProvider,
public_url: &url::Url,
) {
if row.enabled {
let uri = match url::Url::parse(&upstream_callback_url(public_url, &row.slug)) {
Ok(u) => u,
Err(e) => {
tracing::warn!("invalid callback URL for upstream {}: {e}", row.slug);
return;
}
};
let scopes = if row.scopes.is_empty() {
crate::oidc::DEFAULT_UPSTREAM_SCOPES
.iter()
.map(|s| s.to_string())
.collect()
} else {
row.scopes.clone()
};
let provider = crate::oidc::UpstreamProvider {
slug: row.slug.clone(),
issuer: row.issuer.clone(),
client_id: row.client_id.clone(),
client_secret: row.client_secret.clone(),
scopes,
auth_params: row.auth_params.clone(),
};
if let Err(e) = registry.add(provider, uri).await {
tracing::warn!("registry sync failed for upstream {}: {e}", row.slug);
}
} else {
registry.remove(&row.slug);
}
}
pub fn spec_router<S>() -> Router<S>
where
S: Clone + Send + Sync + 'static,
AuthCtx: FromRef<S>,
{
Router::new()
.route(
"/.well-known/openid-configuration",
get(discovery::discovery_handler),
)
.route("/.well-known/jwks.json", get(jwks::jwks_handler))
.route("/authorize", get(handlers::authorize_get))
.route(
"/authorize/consent",
get(consent_preview).post(handlers::consent_post),
)
.route("/token", post(handlers::token_post))
.route(
"/userinfo",
get(handlers::userinfo_get).post(handlers::userinfo_get),
)
.route("/revoke", post(handlers::revoke_post))
.route("/introspect", post(handlers::introspect_post))
.route("/logout", get(handlers::logout_get))
.route("/oidc/upstream/{slug}/start", get(handlers::upstream_start))
.route(
"/oidc/upstream/{slug}/callback",
get(handlers::upstream_callback),
)
.route("/upstreams", get(upstreams_public::list_public))
}
pub fn admin_router<S>() -> Router<S>
where
S: Clone + Send + Sync + 'static,
AuthCtx: FromRef<S>,
crate::state::AdminApiKeys: FromRef<S>,
{
Router::new()
.route(
"/admin/oidc/clients",
get(admin::list_clients).post(admin::create_client),
)
.route(
"/admin/oidc/clients/{id}",
get(admin::get_client)
.put(admin::update_client)
.delete(admin::delete_client),
)
.route(
"/admin/oidc/clients/{id}/rotate-secret",
post(admin::rotate_client_secret),
)
.route(
"/admin/oidc/upstream",
get(admin::list_upstream).post(admin::upsert_upstream),
)
.route(
"/admin/oidc/upstream/{slug}",
get(admin::get_upstream).delete(admin::delete_upstream),
)
}
pub fn router<S>() -> Router<S>
where
S: Clone + Send + Sync + 'static,
AuthCtx: FromRef<S>,
crate::state::AdminApiKeys: FromRef<S>,
{
spec_router::<S>().merge(admin_router::<S>())
}
async fn consent_preview(State(ctx): State<AuthCtx>) -> impl IntoResponse {
let issuer = ctx
.oidc_provider
.as_ref()
.map(|p| p.issuer.clone())
.unwrap_or_default();
let scopes = vec![
"openid".to_string(),
"email".to_string(),
"profile".to_string(),
];
let page = consent::ConsentPage {
client_name: "Example consumer app",
issuer: &issuer,
scopes: &scopes,
csrf_token: "csrf_preview",
resume_token: "resume_preview",
};
Html(page.render_html())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn router_constructs() {
let _: Router<crate::state::AuthCtxWithAdmin> = router();
}
}