siwe-oidc/src/oidc.rs

526 lines
17 KiB
Rust
Raw Normal View History

use anyhow::{anyhow, Result};
use chrono::{Duration, Utc};
use headers::{self, authorization::Bearer};
use hex::FromHex;
use iri_string::types::UriString;
use openidconnect::{
core::{
CoreAuthErrorResponseType, CoreAuthPrompt, CoreClaimName, CoreClientAuthMethod,
CoreClientMetadata, CoreClientRegistrationResponse, CoreErrorResponseType, CoreGrantType,
CoreIdToken, CoreIdTokenClaims, CoreIdTokenFields, CoreJsonWebKeySet,
CoreJwsSigningAlgorithm, CoreProviderMetadata, CoreResponseType, CoreRsaPrivateSigningKey,
CoreSubjectIdentifierType, CoreTokenResponse, CoreTokenType, CoreUserInfoClaims,
},
registration::{EmptyAdditionalClientMetadata, EmptyAdditionalClientRegistrationResponse},
url::Url,
AccessToken, Audience, AuthUrl, ClientId, ClientSecret, EmptyAdditionalClaims,
EmptyAdditionalProviderMetadata, EmptyExtraTokenFields, IssuerUrl, JsonWebKeyId,
JsonWebKeySetUrl, Nonce, PrivateSigningKey, RedirectUrl, RegistrationUrl, RequestUrl,
ResponseTypes, Scope, StandardClaims, SubjectIdentifier, TokenUrl, UserInfoUrl,
};
use rsa::{pkcs1::ToRsaPrivateKey, RsaPrivateKey};
use serde::{Deserialize, Serialize};
use siwe::eip4361::{Message, Version};
use std::str::FromStr;
use thiserror::Error;
use tracing::info;
use urlencoding::decode;
use uuid::Uuid;
#[cfg(target_arch = "wasm32")]
use super::db::*;
#[cfg(not(target_arch = "wasm32"))]
use siwe_oidc::db::*;
const KID: &str = "key1";
pub const METADATA_PATH: &str = "/.well-known/openid-configuration";
pub const JWK_PATH: &str = "/jwk";
pub const TOKEN_PATH: &str = "/token";
pub const AUTHORIZE_PATH: &str = "/authorize";
pub const REGISTER_PATH: &str = "/register";
pub const USERINFO_PATH: &str = "/userinfo";
pub const SIGNIN_PATH: &str = "/sign_in";
pub const SIWE_COOKIE_KEY: &str = "siwe";
#[cfg(not(target_arch = "wasm32"))]
type DBClientType = (dyn DBClient + Sync);
#[cfg(target_arch = "wasm32")]
type DBClientType = dyn DBClient;
#[derive(Serialize, Debug)]
pub struct TokenError {
pub error: CoreErrorResponseType,
pub error_description: String,
}
#[derive(Debug, Error)]
pub enum CustomError {
#[error("{0}")]
BadRequest(String),
#[error("{0:?}")]
BadRequestToken(TokenError),
#[error("{0}")]
Unauthorized(String),
#[error("{0:?}")]
Redirect(String),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
pub fn jwks(private_key: RsaPrivateKey) -> Result<CoreJsonWebKeySet, CustomError> {
let pem = private_key
.to_pkcs1_pem()
.map_err(|e| anyhow!("Failed to serialise key as PEM: {}", e))?;
let jwks = CoreJsonWebKeySet::new(vec![CoreRsaPrivateSigningKey::from_pem(
&pem,
Some(JsonWebKeyId::new(KID.to_string())),
)
.map_err(|e| anyhow!("Invalid RSA private key: {}", e))?
.as_verification_key()]);
Ok(jwks)
}
pub fn metadata(base_url: Url) -> Result<CoreProviderMetadata, CustomError> {
let pm = CoreProviderMetadata::new(
IssuerUrl::from_url(base_url.clone()),
AuthUrl::from_url(
base_url
.join(AUTHORIZE_PATH)
.map_err(|e| anyhow!("Unable to join URL: {}", e))?,
),
JsonWebKeySetUrl::from_url(
base_url
.join(JWK_PATH)
.map_err(|e| anyhow!("Unable to join URL: {}", e))?,
),
vec![
ResponseTypes::new(vec![CoreResponseType::Code]),
ResponseTypes::new(vec![CoreResponseType::Token, CoreResponseType::IdToken]),
],
vec![CoreSubjectIdentifierType::Pairwise],
vec![CoreJwsSigningAlgorithm::RsaSsaPssSha256],
EmptyAdditionalProviderMetadata {},
)
.set_token_endpoint(Some(TokenUrl::from_url(
base_url
.join(TOKEN_PATH)
.map_err(|e| anyhow!("Unable to join URL: {}", e))?,
)))
.set_userinfo_endpoint(Some(UserInfoUrl::from_url(
base_url
.join(USERINFO_PATH)
.map_err(|e| anyhow!("Unable to join URL: {}", e))?,
)))
.set_scopes_supported(Some(vec![
Scope::new("openid".to_string()),
// Scope::new("email".to_string()),
// Scope::new("profile".to_string()),
]))
.set_claims_supported(Some(vec![
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("name".to_string()),
// CoreClaimName::new("given_name".to_string()),
// CoreClaimName::new("family_name".to_string()),
// CoreClaimName::new("picture".to_string()),
// CoreClaimName::new("locale".to_string()),
]))
.set_registration_endpoint(Some(RegistrationUrl::from_url(
base_url
.join(REGISTER_PATH)
.map_err(|e| anyhow!("Unable to join URL: {}", e))?,
)))
.set_token_endpoint_auth_methods_supported(Some(vec![
CoreClientAuthMethod::ClientSecretBasic,
CoreClientAuthMethod::ClientSecretPost,
]));
Ok(pm)
}
#[derive(Serialize, Deserialize)]
pub struct TokenForm {
pub code: String,
pub client_id: Option<String>,
pub client_secret: Option<String>,
pub grant_type: CoreGrantType, // TODO should just be authorization_code apparently?
}
pub async fn token(
form: TokenForm,
// From the request's Authorization header
secret: Option<String>,
private_key: RsaPrivateKey,
base_url: Url,
require_secret: bool,
db_client: &DBClientType,
) -> Result<CoreTokenResponse, CustomError> {
let code_entry = if let Some(c) = db_client.get_code(form.code.to_string()).await? {
c
} else {
return Err(CustomError::BadRequestToken(TokenError {
error: CoreErrorResponseType::InvalidGrant,
error_description: "Unknown code.".to_string(),
}));
};
let client_id = if let Some(c) = form.client_id.clone() {
c
} else {
code_entry.client_id.clone()
};
if let Some(secret) = if let Some(b) = secret {
Some(b)
} else {
form.client_secret.clone()
} {
let client_entry = db_client.get_client(client_id.clone()).await?;
if client_entry.is_none() {
return Err(CustomError::Unauthorized(
"Unrecognised client id.".to_string(),
));
}
if secret != client_entry.unwrap().secret {
return Err(CustomError::Unauthorized("Bad secret.".to_string()));
}
} else if require_secret {
return Err(CustomError::Unauthorized("Secret required.".to_string()));
}
if code_entry.exchange_count > 0 {
// TODO use Oauth error response
return Err(CustomError::BadRequestToken(TokenError {
error: CoreErrorResponseType::InvalidGrant,
error_description: "Code was previously exchanged.".to_string(),
}));
}
let mut code_entry2 = code_entry.clone();
code_entry2.exchange_count += 1;
db_client
.set_code(form.code.to_string(), code_entry2)
.await?;
let access_token = AccessToken::new(form.code);
let core_id_token = CoreIdTokenClaims::new(
IssuerUrl::from_url(base_url),
vec![Audience::new(client_id.clone())],
Utc::now() + Duration::seconds(60),
Utc::now(),
StandardClaims::new(SubjectIdentifier::new(code_entry.address)),
EmptyAdditionalClaims {},
)
2022-01-19 07:40:48 -05:00
.set_nonce(code_entry.nonce)
.set_auth_time(Some(code_entry.auth_time));
let pem = private_key
.to_pkcs1_pem()
.map_err(|e| anyhow!("Failed to serialise key as PEM: {}", e))?;
let id_token = CoreIdToken::new(
core_id_token,
&CoreRsaPrivateSigningKey::from_pem(&pem, Some(JsonWebKeyId::new(KID.to_string())))
.map_err(|e| anyhow!("Invalid RSA private key: {}", e))?,
CoreJwsSigningAlgorithm::RsaSsaPkcs1V15Sha256,
Some(&access_token),
None,
)
.map_err(|e| anyhow!("{}", e))?;
Ok(CoreTokenResponse::new(
access_token,
CoreTokenType::Bearer,
CoreIdTokenFields::new(Some(id_token), EmptyExtraTokenFields {}),
))
}
#[derive(Deserialize)]
pub struct AuthorizeParams {
pub client_id: String,
pub redirect_uri: RedirectUrl,
pub scope: Scope,
pub response_type: Option<CoreResponseType>,
pub state: Option<String>,
pub nonce: Option<Nonce>,
pub prompt: Option<CoreAuthPrompt>,
pub request_uri: Option<RequestUrl>,
pub request: Option<String>,
}
pub async fn authorize(
params: AuthorizeParams,
nonce: String,
db_client: &DBClientType,
) -> Result<String, CustomError> {
let client_entry = db_client
.get_client(params.client_id.clone())
.await
.map_err(|e| anyhow!("Failed to get kv: {}", e))?;
if client_entry.is_none() {
return Err(CustomError::Unauthorized(
"Unrecognised client id.".to_string(),
));
}
let mut r_u = params.redirect_uri.clone().url().clone();
r_u.set_query(None);
let mut r_us: Vec<Url> = client_entry
.unwrap()
.redirect_uris
.iter_mut()
.map(|u| u.url().clone())
.collect();
r_us.iter_mut().for_each(|u| u.set_query(None));
if !r_us.contains(&r_u) {
return Err(CustomError::Redirect(
"/error?message=unregistered_request_uri".to_string(),
));
}
let state = if let Some(s) = params.state.clone() {
s
} else if params.request_uri.is_some() {
let mut url = params.redirect_uri.url().clone();
url.query_pairs_mut().append_pair(
"error",
CoreAuthErrorResponseType::RequestUriNotSupported.as_ref(),
);
return Err(CustomError::Redirect(url.to_string()));
} else if params.request.is_some() {
let mut url = params.redirect_uri.url().clone();
url.query_pairs_mut().append_pair(
"error",
CoreAuthErrorResponseType::RequestNotSupported.as_ref(),
);
return Err(CustomError::Redirect(url.to_string()));
} else {
let mut url = params.redirect_uri.url().clone();
url.query_pairs_mut()
.append_pair("error", CoreAuthErrorResponseType::InvalidRequest.as_ref());
url.query_pairs_mut()
.append_pair("error_description", "Missing state");
return Err(CustomError::Redirect(url.to_string()));
};
if let Some(CoreAuthPrompt::None) = params.prompt {
let mut url = params.redirect_uri.url().clone();
url.query_pairs_mut().append_pair("state", &state);
url.query_pairs_mut().append_pair(
"error",
CoreAuthErrorResponseType::InteractionRequired.as_ref(),
);
return Err(CustomError::Redirect(url.to_string()));
}
if params.response_type.is_none() {
let mut url = params.redirect_uri.url().clone();
url.query_pairs_mut().append_pair("state", &state);
url.query_pairs_mut()
.append_pair("error", CoreAuthErrorResponseType::InvalidRequest.as_ref());
url.query_pairs_mut()
.append_pair("error_description", "Missing response_type");
return Err(CustomError::Redirect(url.to_string()));
}
let _response_type = params.response_type.as_ref().unwrap();
if params.scope != Scope::new("openid".to_string()) {
return Err(anyhow!("Scope not supported").into());
}
let domain = params.redirect_uri.url().host().unwrap();
let oidc_nonce_param = if let Some(n) = &params.nonce {
format!("&oidc_nonce={}", n.secret())
} else {
"".to_string()
};
Ok(format!(
"/?nonce={}&domain={}&redirect_uri={}&state={}&client_id={}{}",
nonce,
domain,
params.redirect_uri.to_string(),
state,
params.client_id,
oidc_nonce_param
))
}
#[derive(Serialize, Deserialize)]
pub struct SiweCookie {
message: Web3ModalMessage,
signature: String,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Web3ModalMessage {
pub domain: String,
pub address: String,
pub statement: String,
pub uri: String,
pub version: String,
pub chain_id: String,
pub nonce: String,
pub issued_at: String,
pub expiration_time: Option<String>,
pub not_before: Option<String>,
pub request_id: Option<String>,
pub resources: Option<Vec<String>>,
}
impl Web3ModalMessage {
fn to_eip4361_message(&self) -> Result<Message> {
let mut next_resources: Vec<UriString> = Vec::new();
match &self.resources {
Some(resources) => {
for resource in resources {
let x = UriString::from_str(resource)?;
next_resources.push(x)
}
}
None => {}
}
Ok(Message {
domain: self.domain.clone().try_into()?,
address: <[u8; 20]>::from_hex(self.address.chars().skip(2).collect::<String>())?,
statement: self.statement.to_string(),
uri: UriString::from_str(&self.uri)?,
version: Version::from_str(&self.version)?,
chain_id: self.chain_id.to_string(),
nonce: self.nonce.to_string(),
issued_at: self.issued_at.to_string(),
expiration_time: self.expiration_time.clone(),
not_before: self.not_before.clone(),
request_id: self.request_id.clone(),
resources: next_resources,
})
}
}
#[derive(Deserialize)]
pub struct SignInParams {
pub redirect_uri: RedirectUrl,
pub state: String,
pub oidc_nonce: Option<Nonce>,
pub client_id: String,
}
pub async fn sign_in(
params: SignInParams,
expected_nonce: Option<String>,
cookies: headers::Cookie,
db_client: &DBClientType,
) -> Result<Url, CustomError> {
let siwe_cookie: SiweCookie = match cookies.get(SIWE_COOKIE_KEY) {
Some(c) => serde_json::from_str(
&decode(c).map_err(|e| anyhow!("Could not decode siwe cookie: {}", e))?,
)
.map_err(|e| anyhow!("Could not deserialize siwe cookie: {}", e))?,
None => {
return Err(anyhow!("No `siwe` cookie").into());
}
};
let signature = match <[u8; 65]>::from_hex(
siwe_cookie
.signature
.chars()
.skip(2)
.take(130)
.collect::<String>(),
) {
Ok(s) => s,
Err(e) => {
return Err(CustomError::BadRequest(format!("Bad signature: {}", e)));
}
};
let message = siwe_cookie
.message
.to_eip4361_message()
.map_err(|e| anyhow!("Failed to serialise message: {}", e))?;
info!("{}", message);
message
.verify(signature)
.map_err(|e| anyhow!("Failed signature validation: {}", e))?;
let domain = params.redirect_uri.url().host().unwrap();
if domain.to_string() != siwe_cookie.message.domain {
return Err(anyhow!("Conflicting domains in message and redirect").into());
}
if expected_nonce.is_some() && expected_nonce.unwrap() != siwe_cookie.message.nonce {
return Err(anyhow!("Conflicting nonces in message and session").into());
}
let code_entry = CodeEntry {
address: siwe_cookie.message.address,
nonce: params.oidc_nonce.clone(),
exchange_count: 0,
client_id: params.client_id.clone(),
2022-01-19 07:40:48 -05:00
auth_time: chrono::offset::Utc::now(),
};
let code = Uuid::new_v4();
db_client.set_code(code.to_string(), code_entry).await?;
let mut url = params.redirect_uri.url().clone();
url.query_pairs_mut().append_pair("code", &code.to_string());
url.query_pairs_mut().append_pair("state", &params.state);
Ok(url)
}
pub async fn register(
payload: CoreClientMetadata,
db_client: &DBClientType,
) -> Result<CoreClientRegistrationResponse, CustomError> {
let id = Uuid::new_v4();
let secret = Uuid::new_v4();
let entry = ClientEntry {
secret: secret.to_string(),
redirect_uris: payload.redirect_uris().to_vec(),
};
db_client.set_client(id.to_string(), entry).await?;
Ok(CoreClientRegistrationResponse::new(
ClientId::new(id.to_string()),
payload.redirect_uris().to_vec(),
EmptyAdditionalClientMetadata::default(),
EmptyAdditionalClientRegistrationResponse::default(),
)
.set_client_secret(Some(ClientSecret::new(secret.to_string()))))
}
#[derive(Deserialize)]
pub struct UserInfoPayload {
pub access_token: Option<String>,
}
pub async fn userinfo(
bearer: Option<Bearer>,
payload: UserInfoPayload,
db_client: &DBClientType,
) -> Result<CoreUserInfoClaims, CustomError> {
let code = if let Some(b) = bearer {
b.token().to_string()
} else if let Some(c) = payload.access_token {
c
} else {
return Err(CustomError::BadRequest("Missing access token.".to_string()));
};
let code_entry = if let Some(c) = db_client.get_code(code).await? {
c
} else {
return Err(CustomError::BadRequest("Unknown code.".to_string()));
};
Ok(CoreUserInfoClaims::new(
StandardClaims::new(SubjectIdentifier::new(code_entry.address)),
EmptyAdditionalClaims::default(),
))
}