feat: add oidc and basic support for playground
This commit is contained in:
@@ -22,12 +22,12 @@ impl AuthBasic {
|
||||
.headers
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|s| s.to_str().ok())
|
||||
.ok_or_else(|| AuthError::BasicInvalidCredentials)?;
|
||||
.ok_or(AuthError::BasicInvalidCredentials)?;
|
||||
|
||||
let split = authorization.split_once(' ');
|
||||
|
||||
match split {
|
||||
Some((name, contents)) if name == "Basic" => {
|
||||
Some(("Basic", contents)) => {
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(contents)
|
||||
.map_err(|_| AuthError::BasicInvalidCredentials)?;
|
||||
@@ -80,4 +80,8 @@ impl AuthService for BasicAuthService {
|
||||
fn www_authenticate_header_value(&self) -> Option<HeaderValue> {
|
||||
Some(HeaderValue::from_static(r#"Basic realm="konobangu""#))
|
||||
}
|
||||
|
||||
fn auth_type(&self) -> AuthType {
|
||||
AuthType::Basic
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use jwt_authorizer::OneOrArray;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::{serde_as, NoneAsEmptyString};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BasicAuthConfig {
|
||||
@@ -9,17 +10,24 @@ pub struct BasicAuthConfig {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OidcAuthConfig {
|
||||
#[serde(rename = "oidc_api_issuer")]
|
||||
#[serde(rename = "oidc_issuer")]
|
||||
pub issuer: String,
|
||||
#[serde(rename = "oidc_api_audience")]
|
||||
#[serde(rename = "oidc_audience")]
|
||||
pub audience: String,
|
||||
#[serde(rename = "oidc_client_id")]
|
||||
pub client_id: String,
|
||||
#[serde(rename = "oidc_client_secret")]
|
||||
pub client_secret: String,
|
||||
#[serde(rename = "oidc_extra_scopes")]
|
||||
pub extra_scopes: Option<OneOrArray<String>>,
|
||||
#[serde_as(as = "NoneAsEmptyString")]
|
||||
#[serde(rename = "oidc_extra_claim_key")]
|
||||
pub extra_claim_key: Option<String>,
|
||||
#[serde(rename = "oidc_extra_claim_value")]
|
||||
#[serde_as(as = "NoneAsEmptyString")]
|
||||
pub extra_claim_value: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
31
apps/recorder/src/auth/config.ts
Normal file
31
apps/recorder/src/auth/config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { type OidcClientSettings, UserManager } from 'oidc-client-ts';
|
||||
|
||||
export const PostLoginRedirectUriKey = 'post_login_redirect_uri';
|
||||
|
||||
export function buildOidcConfig(): OidcClientSettings {
|
||||
const origin = window.location.origin;
|
||||
|
||||
const resource = process.env.OIDC_AUDIENCE!;
|
||||
|
||||
return {
|
||||
authority: process.env.OIDC_ISSUER!,
|
||||
client_id: process.env.OIDC_CLIENT_ID!,
|
||||
client_secret: process.env.OIDC_CLIENT_SECRET!,
|
||||
redirect_uri: `${origin}/api/playground/oidc/callback`,
|
||||
disablePKCE: false,
|
||||
scope: `openid profile email ${process.env.OIDC_EXTRA_SCOPES}`,
|
||||
response_type: 'code',
|
||||
resource,
|
||||
post_logout_redirect_uri: `${origin}/api/playground`,
|
||||
extraQueryParams: {
|
||||
resource,
|
||||
},
|
||||
extraTokenParams: {
|
||||
resource,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUserManager(): UserManager {
|
||||
return new UserManager(buildOidcConfig());
|
||||
}
|
||||
@@ -3,15 +3,56 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use openidconnect::{
|
||||
core::CoreErrorResponseType, ConfigurationError, RequestTokenError, SignatureVerificationError,
|
||||
SigningError, StandardErrorResponse,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{fetch::HttpClientError, models::auth::AuthType};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AuthError {
|
||||
#[error("Not support auth method")]
|
||||
NotSupportAuthMethod {
|
||||
supported: Vec<AuthType>,
|
||||
current: AuthType,
|
||||
},
|
||||
#[error("Invalid credentials")]
|
||||
BasicInvalidCredentials,
|
||||
#[error(transparent)]
|
||||
OidcInitError(#[from] jwt_authorizer::error::InitError),
|
||||
#[error("Invalid oidc provider meta client error: {0}")]
|
||||
OidcProviderHttpClientError(HttpClientError),
|
||||
#[error(transparent)]
|
||||
OidcProviderMetaError(#[from] openidconnect::DiscoveryError<HttpClientError>),
|
||||
#[error("Invalid oidc provider URL: {0}")]
|
||||
OidcProviderUrlError(url::ParseError),
|
||||
#[error("Invalid oidc redirect URI: {0}")]
|
||||
OidcRequestRedirectUriError(url::ParseError),
|
||||
#[error("Oidc request session not found or expired")]
|
||||
OidcCallbackRecordNotFoundOrExpiredError,
|
||||
#[error("Invalid oidc request callback nonce")]
|
||||
OidcInvalidNonceError,
|
||||
#[error("Invalid oidc request callback state")]
|
||||
OidcInvalidStateError,
|
||||
#[error("Invalid oidc request callback code")]
|
||||
OidcInvalidCodeError,
|
||||
#[error(transparent)]
|
||||
OidcCallbackTokenConfigrationError(#[from] ConfigurationError),
|
||||
#[error(transparent)]
|
||||
OidcRequestTokenError(
|
||||
#[from] RequestTokenError<HttpClientError, StandardErrorResponse<CoreErrorResponseType>>,
|
||||
),
|
||||
#[error("Invalid oidc id token")]
|
||||
OidcInvalidIdTokenError,
|
||||
#[error("Invalid oidc access token")]
|
||||
OidcInvalidAccessTokenError,
|
||||
#[error(transparent)]
|
||||
OidcSignatureVerificationError(#[from] SignatureVerificationError),
|
||||
#[error(transparent)]
|
||||
OidcSigningError(#[from] SigningError),
|
||||
#[error(transparent)]
|
||||
OidcJwtAuthError(#[from] jwt_authorizer::AuthError),
|
||||
#[error("Extra scopes {expected} do not match found scopes {found}")]
|
||||
|
||||
21
apps/recorder/src/auth/guard.ts
Normal file
21
apps/recorder/src/auth/guard.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ParsedLocation } from '@tanstack/react-router';
|
||||
import type { RouterContext } from '../controllers/__root';
|
||||
import { PostLoginRedirectUriKey } from './config';
|
||||
|
||||
export const beforeLoadGuard = async ({
|
||||
context,
|
||||
location,
|
||||
// biome-ignore lint/complexity/noBannedTypes: <explanation>
|
||||
}: { context: RouterContext; location: ParsedLocation<{}> }) => {
|
||||
if (!context.isAuthenticated) {
|
||||
// TODO: FIXME
|
||||
const user = await context.userManager.getUser();
|
||||
if (!user) {
|
||||
try {
|
||||
sessionStorage.setItem(PostLoginRedirectUriKey, location.href);
|
||||
// biome-ignore lint/suspicious/noEmptyBlockStatements: <explanation>
|
||||
} catch {}
|
||||
throw await context.auth.signinRedirect();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::header,
|
||||
@@ -9,34 +11,7 @@ use loco_rs::prelude::AppContext;
|
||||
use crate::{app::AppContextExt, auth::AuthService};
|
||||
|
||||
pub async fn api_auth_middleware(
|
||||
State(ctx): State<AppContext>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let auth_service = ctx.get_auth_service();
|
||||
|
||||
let (mut parts, body) = request.into_parts();
|
||||
|
||||
let mut response = match auth_service.extract_user_info(&mut parts).await {
|
||||
Ok(auth_user_info) => {
|
||||
let mut request = Request::from_parts(parts, body);
|
||||
request.extensions_mut().insert(auth_user_info);
|
||||
next.run(request).await
|
||||
}
|
||||
Err(auth_error) => auth_error.into_response(),
|
||||
};
|
||||
|
||||
if let Some(header_value) = auth_service.www_authenticate_header_value() {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::WWW_AUTHENTICATE, header_value);
|
||||
};
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
pub async fn webui_auth_middleware(
|
||||
State(ctx): State<AppContext>,
|
||||
State(ctx): State<Arc<AppContext>>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
|
||||
@@ -7,5 +7,5 @@ pub mod service;
|
||||
|
||||
pub use config::{AppAuthConfig, BasicAuthConfig, OidcAuthConfig};
|
||||
pub use errors::AuthError;
|
||||
pub use middleware::{api_auth_middleware, webui_auth_middleware};
|
||||
pub use middleware::api_auth_middleware;
|
||||
pub use service::{AppAuthService, AuthService, AuthUserInfo};
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::http::{request::Parts, HeaderValue};
|
||||
use itertools::Itertools;
|
||||
use jwt_authorizer::{authorizer::Authorizer, NumericDate, OneOrArray};
|
||||
use moka::future::Cache;
|
||||
use openidconnect::{
|
||||
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata},
|
||||
AccessTokenHash, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce,
|
||||
OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, TokenResponse,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use url::Url;
|
||||
|
||||
use super::{
|
||||
config::OidcAuthConfig,
|
||||
errors::AuthError,
|
||||
service::{AuthService, AuthUserInfo},
|
||||
};
|
||||
use crate::models::auth::AuthType;
|
||||
use crate::{fetch::HttpClient, models::auth::AuthType};
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug)]
|
||||
pub struct OidcAuthClaims {
|
||||
@@ -76,23 +86,185 @@ impl OidcAuthClaims {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct OidcAuthRequest {
|
||||
pub auth_uri: Url,
|
||||
#[serde(skip)]
|
||||
pub redirect_uri: RedirectUrl,
|
||||
#[serde(skip)]
|
||||
pub csrf_token: CsrfToken,
|
||||
#[serde(skip)]
|
||||
pub nonce: Nonce,
|
||||
#[serde(skip)]
|
||||
pub pkce_verifier: Arc<PkceCodeVerifier>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OidcAuthCallbackQuery {
|
||||
pub state: Option<String>,
|
||||
pub code: Option<String>,
|
||||
pub redirect_uri: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OidcAuthCallbackPayload {
|
||||
pub access_token: String,
|
||||
}
|
||||
|
||||
pub struct OidcAuthService {
|
||||
pub config: OidcAuthConfig,
|
||||
pub authorizer: Authorizer<OidcAuthClaims>,
|
||||
pub api_authorizer: Authorizer<OidcAuthClaims>,
|
||||
pub oidc_provider_client: HttpClient,
|
||||
pub oidc_request_cache: Cache<String, OidcAuthRequest>,
|
||||
}
|
||||
|
||||
impl OidcAuthService {
|
||||
pub async fn build_authorization_request(
|
||||
&self,
|
||||
redirect_uri: &str,
|
||||
) -> Result<OidcAuthRequest, AuthError> {
|
||||
let provider_metadata = CoreProviderMetadata::discover_async(
|
||||
IssuerUrl::new(self.config.issuer.clone()).map_err(AuthError::OidcProviderUrlError)?,
|
||||
&self.oidc_provider_client,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let redirect_uri = RedirectUrl::new(redirect_uri.to_string())
|
||||
.map_err(AuthError::OidcRequestRedirectUriError)?;
|
||||
|
||||
let oidc_client = CoreClient::from_provider_metadata(
|
||||
provider_metadata,
|
||||
ClientId::new(self.config.client_id.clone()),
|
||||
Some(ClientSecret::new(self.config.client_secret.clone())),
|
||||
)
|
||||
.set_redirect_uri(redirect_uri.clone());
|
||||
|
||||
let (pkce_chanllenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||
|
||||
let mut authorization_request = oidc_client
|
||||
.authorize_url(
|
||||
CoreAuthenticationFlow::AuthorizationCode,
|
||||
CsrfToken::new_random,
|
||||
Nonce::new_random,
|
||||
)
|
||||
.set_pkce_challenge(pkce_chanllenge);
|
||||
|
||||
{
|
||||
if let Some(scopes) = self.config.extra_scopes.as_ref() {
|
||||
authorization_request = authorization_request.add_scopes(
|
||||
scopes
|
||||
.iter()
|
||||
.map(|s| openidconnect::Scope::new(s.to_string())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let (auth_uri, csrf_token, nonce) = authorization_request.url();
|
||||
|
||||
Ok(OidcAuthRequest {
|
||||
auth_uri,
|
||||
csrf_token,
|
||||
nonce,
|
||||
pkce_verifier: Arc::new(pkce_verifier),
|
||||
redirect_uri,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn store_authorization_request(
|
||||
&self,
|
||||
request: OidcAuthRequest,
|
||||
) -> Result<(), AuthError> {
|
||||
self.oidc_request_cache
|
||||
.insert(request.csrf_token.secret().to_string(), request)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_authorization_request(
|
||||
&self,
|
||||
state: &str,
|
||||
) -> Result<OidcAuthRequest, AuthError> {
|
||||
let result = self
|
||||
.oidc_request_cache
|
||||
.get(state)
|
||||
.await
|
||||
.ok_or(AuthError::OidcCallbackRecordNotFoundOrExpiredError)?;
|
||||
|
||||
self.oidc_request_cache.invalidate(state).await;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn extract_authorization_request_callback(
|
||||
&self,
|
||||
query: OidcAuthCallbackQuery,
|
||||
) -> Result<OidcAuthCallbackPayload, AuthError> {
|
||||
let csrf_token = query.state.ok_or(AuthError::OidcInvalidStateError)?;
|
||||
|
||||
let code = query.code.ok_or(AuthError::OidcInvalidCodeError)?;
|
||||
|
||||
let request_cache = self.load_authorization_request(&csrf_token).await?;
|
||||
|
||||
let provider_metadata = CoreProviderMetadata::discover_async(
|
||||
IssuerUrl::new(self.config.issuer.clone()).map_err(AuthError::OidcProviderUrlError)?,
|
||||
&self.oidc_provider_client,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let oidc_client = CoreClient::from_provider_metadata(
|
||||
provider_metadata,
|
||||
ClientId::new(self.config.client_id.clone()),
|
||||
Some(ClientSecret::new(self.config.client_secret.clone())),
|
||||
)
|
||||
.set_redirect_uri(request_cache.redirect_uri);
|
||||
|
||||
let pkce_verifier = PkceCodeVerifier::new(request_cache.pkce_verifier.secret().to_string());
|
||||
|
||||
let token_response = oidc_client
|
||||
.exchange_code(AuthorizationCode::new(code))?
|
||||
.set_pkce_verifier(pkce_verifier)
|
||||
.request_async(&HttpClient::default())
|
||||
.await?;
|
||||
|
||||
let id_token = token_response
|
||||
.id_token()
|
||||
.ok_or(AuthError::OidcInvalidIdTokenError)?;
|
||||
|
||||
let id_token_verifier = &oidc_client.id_token_verifier();
|
||||
|
||||
let claims = id_token
|
||||
.claims(id_token_verifier, &request_cache.nonce)
|
||||
.map_err(|_| AuthError::OidcInvalidNonceError)?;
|
||||
|
||||
let access_token = token_response.access_token();
|
||||
|
||||
let actual_access_token_hash = AccessTokenHash::from_token(
|
||||
access_token,
|
||||
id_token.signing_alg()?,
|
||||
id_token.signing_key(id_token_verifier)?,
|
||||
)?;
|
||||
|
||||
if let Some(expected_access_token_hash) = claims.access_token_hash() {
|
||||
if actual_access_token_hash != *expected_access_token_hash {
|
||||
return Err(AuthError::OidcInvalidAccessTokenError);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(OidcAuthCallbackPayload {
|
||||
access_token: access_token.secret().to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthService for OidcAuthService {
|
||||
async fn extract_user_info(&self, request: &mut Parts) -> Result<AuthUserInfo, AuthError> {
|
||||
let config = &self.config;
|
||||
let token =
|
||||
self.authorizer
|
||||
.extract_token(&request.headers)
|
||||
.ok_or(AuthError::OidcJwtAuthError(
|
||||
jwt_authorizer::AuthError::MissingToken(),
|
||||
))?;
|
||||
let token = self.api_authorizer.extract_token(&request.headers).ok_or(
|
||||
AuthError::OidcJwtAuthError(jwt_authorizer::AuthError::MissingToken()),
|
||||
)?;
|
||||
|
||||
let token_data = self.authorizer.check_auth(&token).await?;
|
||||
let token_data = self.api_authorizer.check_auth(&token).await?;
|
||||
let claims = token_data.claims;
|
||||
if claims.sub.as_deref().is_none_or(|s| s.trim().is_empty()) {
|
||||
return Err(AuthError::OidcSubMissingError);
|
||||
@@ -139,4 +311,8 @@ impl AuthService for OidcAuthService {
|
||||
fn www_authenticate_header_value(&self) -> Option<HeaderValue> {
|
||||
Some(HeaderValue::from_static(r#"Bearer realm="konobangu""#))
|
||||
}
|
||||
|
||||
fn auth_type(&self) -> AuthType {
|
||||
AuthType::Oidc
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
@@ -6,6 +8,7 @@ use axum::{
|
||||
};
|
||||
use jwt_authorizer::{JwtAuthorizer, Validation};
|
||||
use loco_rs::app::{AppContext, Initializer};
|
||||
use moka::future::Cache;
|
||||
use once_cell::sync::OnceCell;
|
||||
use reqwest::header::HeaderValue;
|
||||
|
||||
@@ -15,7 +18,15 @@ use super::{
|
||||
oidc::{OidcAuthClaims, OidcAuthService},
|
||||
AppAuthConfig,
|
||||
};
|
||||
use crate::{app::AppContextExt as _, config::AppConfigExt, models::auth::AuthType};
|
||||
use crate::{
|
||||
app::AppContextExt as _,
|
||||
config::AppConfigExt,
|
||||
fetch::{
|
||||
client::{HttpClientCacheBackendConfig, HttpClientCachePresetConfig},
|
||||
HttpClient, HttpClientConfig,
|
||||
},
|
||||
models::auth::AuthType,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthUserInfo {
|
||||
@@ -43,6 +54,7 @@ impl FromRequestParts<AppContext> for AuthUserInfo {
|
||||
pub trait AuthService {
|
||||
async fn extract_user_info(&self, request: &mut Parts) -> Result<AuthUserInfo, AuthError>;
|
||||
fn www_authenticate_header_value(&self) -> Option<HeaderValue>;
|
||||
fn auth_type(&self) -> AuthType;
|
||||
}
|
||||
|
||||
pub enum AppAuthService {
|
||||
@@ -74,7 +86,18 @@ impl AppAuthService {
|
||||
|
||||
AppAuthService::Oidc(OidcAuthService {
|
||||
config,
|
||||
authorizer: jwt_auth,
|
||||
api_authorizer: jwt_auth,
|
||||
oidc_provider_client: HttpClient::from_config(HttpClientConfig {
|
||||
exponential_backoff_max_retries: Some(3),
|
||||
cache_backend: Some(HttpClientCacheBackendConfig::Moka { cache_size: 1 }),
|
||||
cache_preset: Some(HttpClientCachePresetConfig::RFC7234),
|
||||
..Default::default()
|
||||
})
|
||||
.map_err(AuthError::OidcProviderHttpClientError)?,
|
||||
oidc_request_cache: Cache::builder()
|
||||
.time_to_live(Duration::from_mins(5))
|
||||
.name("oidc_request_cache")
|
||||
.build(),
|
||||
})
|
||||
}
|
||||
};
|
||||
@@ -97,6 +120,13 @@ impl AuthService for AppAuthService {
|
||||
AppAuthService::Oidc(service) => service.www_authenticate_header_value(),
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_type(&self) -> AuthType {
|
||||
match self {
|
||||
AppAuthService::Basic(service) => service.auth_type(),
|
||||
AppAuthService::Oidc(service) => service.auth_type(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AppAuthServiceInitializer;
|
||||
|
||||
Reference in New Issue
Block a user