use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub struct ProviderSpec {
pub id: &'static str,
pub display_name: &'static str,
pub auth_url: &'static str,
pub token_url: &'static str,
pub userinfo_url: Option<&'static str>,
pub scopes: &'static str,
pub scope_separator: &'static str,
pub client_id_param: &'static str,
pub auth_query_extra: &'static str,
pub requires_pkce: bool,
pub userinfo_method: UserinfoMethod,
pub userinfo_parser: UserinfoParser,
pub token_exchange: TokenExchangeShape,
pub token_response_json: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UserinfoMethod {
Get,
Post,
}
#[derive(Debug, Clone)]
pub enum UserinfoParser {
Oidc,
GitHub,
LinearGraphql,
AppleIdToken,
Custom {
id_path: &'static str,
email_path: &'static str,
name_path: Option<&'static str>,
},
}
#[derive(Debug, Clone)]
pub enum TokenExchangeShape {
Standard,
AppleJwt,
BasicAuth,
JsonBody,
BasicAuthJsonBody,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderConfig {
pub provider: String,
pub client_id: String,
pub client_secret: String,
pub redirect_uri: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scopes_override: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub apple: Option<AppleConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oidc_issuer: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppleConfig {
pub team_id: String,
pub key_id: String,
pub private_key_pem: String,
}
pub mod builtin {
use super::*;
pub fn all() -> &'static [&'static ProviderSpec] {
ALL
}
static ALL: &[&ProviderSpec] = &[
&GOOGLE,
&GITHUB,
&APPLE,
&MICROSOFT,
&DISCORD,
&SLACK,
&SPOTIFY,
&TWITCH,
&TWITTER,
&LINKEDIN,
&FACEBOOK,
&GITLAB,
&REDDIT,
&NOTION,
&LINEAR,
&VERCEL,
&ZOOM,
&SALESFORCE,
&ATLASSIAN,
&FIGMA,
&DROPBOX,
&TIKTOK,
&PAYPAL,
&KICK,
&ROBLOX,
];
pub static GOOGLE: ProviderSpec = ProviderSpec {
id: "google",
display_name: "Google",
auth_url: "https://accounts.google.com/o/oauth2/v2/auth",
token_url: "https://oauth2.googleapis.com/token",
userinfo_url: Some("https://www.googleapis.com/oauth2/v3/userinfo"),
scopes: "openid email profile",
scope_separator: " ",
client_id_param: "client_id",
auth_query_extra: "",
requires_pkce: false,
userinfo_method: UserinfoMethod::Get,
userinfo_parser: UserinfoParser::Oidc,
token_exchange: TokenExchangeShape::Standard,
token_response_json: true,
};
pub static GITHUB: ProviderSpec = ProviderSpec {
id: "github",
display_name: "GitHub",
auth_url: "https://github.com/login/oauth/authorize",
token_url: "https://github.com/login/oauth/access_token",
userinfo_url: Some("https://api.github.com/user"),
scopes: "user:email",
scope_separator: " ",
client_id_param: "client_id",
auth_query_extra: "",
requires_pkce: false,
userinfo_method: UserinfoMethod::Get,
userinfo_parser: UserinfoParser::GitHub,
token_exchange: TokenExchangeShape::Standard,
token_response_json: true,
};
pub static APPLE: ProviderSpec = ProviderSpec {
id: "apple",
display_name: "Apple",
auth_url: "https://appleid.apple.com/auth/authorize",
token_url: "https://appleid.apple.com/auth/token",
userinfo_url: None,
scopes: "name email",
scope_separator: " ",
client_id_param: "client_id",
auth_query_extra: "response_mode=form_post",
requires_pkce: false,
userinfo_method: UserinfoMethod::Get,
userinfo_parser: UserinfoParser::AppleIdToken,
token_exchange: TokenExchangeShape::AppleJwt,
token_response_json: true,
};
pub static MICROSOFT: ProviderSpec = ProviderSpec {
id: "microsoft",
display_name: "Microsoft",
auth_url: "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize",
token_url: "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token",
userinfo_url: Some("https://graph.microsoft.com/oidc/userinfo"),
scopes: "openid email profile",
scope_separator: " ",
client_id_param: "client_id",
auth_query_extra: "",
requires_pkce: false,
userinfo_method: UserinfoMethod::Get,
userinfo_parser: UserinfoParser::Oidc,
token_exchange: TokenExchangeShape::Standard,
token_response_json: true,
};
pub static DISCORD: ProviderSpec = ProviderSpec {
id: "discord",
display_name: "Discord",
auth_url: "https://discord.com/oauth2/authorize",
token_url: "https://discord.com/api/oauth2/token",
userinfo_url: Some("https://discord.com/api/users/@me"),
scopes: "identify email",
scope_separator: " ",
client_id_param: "client_id",
auth_query_extra: "",
requires_pkce: false,
userinfo_method: UserinfoMethod::Get,
userinfo_parser: UserinfoParser::Custom {
id_path: "/id",
email_path: "/email",
name_path: Some("/global_name"),
},
token_exchange: TokenExchangeShape::Standard,
token_response_json: true,
};
pub static SLACK: ProviderSpec = ProviderSpec {
id: "slack",
display_name: "Slack",
auth_url: "https://slack.com/openid/connect/authorize",
token_url: "https://slack.com/api/openid.connect.token",
userinfo_url: Some("https://slack.com/api/openid.connect.userInfo"),
scopes: "openid email profile",
scope_separator: " ",
client_id_param: "client_id",
auth_query_extra: "",
requires_pkce: false,
userinfo_method: UserinfoMethod::Get,
userinfo_parser: UserinfoParser::Oidc,
token_exchange: TokenExchangeShape::Standard,
token_response_json: true,
};
pub static SPOTIFY: ProviderSpec = ProviderSpec {
id: "spotify",
display_name: "Spotify",
auth_url: "https://accounts.spotify.com/authorize",
token_url: "https://accounts.spotify.com/api/token",
userinfo_url: Some("https://api.spotify.com/v1/me"),
scopes: "user-read-email user-read-private",
scope_separator: " ",
client_id_param: "client_id",
auth_query_extra: "",
requires_pkce: false,
userinfo_method: UserinfoMethod::Get,
userinfo_parser: UserinfoParser::Custom {
id_path: "/id",
email_path: "/email",
name_path: Some("/display_name"),
},
token_exchange: TokenExchangeShape::BasicAuth,
token_response_json: true,
};
pub static TWITCH: ProviderSpec = ProviderSpec {
id: "twitch",
display_name: "Twitch",
auth_url: "https://id.twitch.tv/oauth2/authorize",
token_url: "https://id.twitch.tv/oauth2/token",
userinfo_url: Some("https://id.twitch.tv/oauth2/userinfo"),
scopes: "openid user:read:email",
scope_separator: " ",
client_id_param: "client_id",
auth_query_extra: "",
requires_pkce: false,
userinfo_method: UserinfoMethod::Get,
userinfo_parser: UserinfoParser::Oidc,
token_exchange: TokenExchangeShape::Standard,
token_response_json: true,
};
pub static TWITTER: ProviderSpec = ProviderSpec {
id: "twitter",
display_name: "Twitter / X",
auth_url: "https://twitter.com/i/oauth2/authorize",
token_url: "https://api.twitter.com/2/oauth2/token",
userinfo_url: Some("https://api.twitter.com/2/users/me?user.fields=id,name,username"),
scopes: "users.read tweet.read",
scope_separator: " ",
client_id_param: "client_id",
auth_query_extra: "",
requires_pkce: true,
userinfo_method: UserinfoMethod::Get,
userinfo_parser: UserinfoParser::Custom {
id_path: "/data/id",
email_path: "/data/username",
name_path: Some("/data/name"),
},
token_exchange: TokenExchangeShape::BasicAuth,
token_response_json: true,
};
pub static LINKEDIN: ProviderSpec = ProviderSpec {
id: "linkedin",
display_name: "LinkedIn",
auth_url: "https://www.linkedin.com/oauth/v2/authorization",
token_url: "https://www.linkedin.com/oauth/v2/accessToken",
userinfo_url: Some("https://api.linkedin.com/v2/userinfo"),
scopes: "openid profile email",
scope_separator: " ",
client_id_param: "client_id",
auth_query_extra: "",
requires_pkce: false,
userinfo_method: UserinfoMethod::Get,
userinfo_parser: UserinfoParser::Oidc,
token_exchange: TokenExchangeShape::Standard,
token_response_json: true,
};
pub static FACEBOOK: ProviderSpec = ProviderSpec {
id: "facebook",
display_name: "Facebook",
auth_url: "https://www.facebook.com/v18.0/dialog/oauth",
token_url: "https://graph.facebook.com/v18.0/oauth/access_token",
userinfo_url: Some("https://graph.facebook.com/me?fields=id,email,name"),
scopes: "email public_profile",
scope_separator: " ",
client_id_param: "client_id",
auth_query_extra: "",
requires_pkce: false,
userinfo_method: UserinfoMethod::Get,
userinfo_parser: UserinfoParser::Custom {
id_path: "/id",
email_path: "/email",
name_path: Some("/name"),
},
token_exchange: TokenExchangeShape::Standard,
token_response_json: true,
};
pub static GITLAB: ProviderSpec = ProviderSpec {
id: "gitlab",
display_name: "GitLab",
auth_url: "https://gitlab.com/oauth/authorize",
token_url: "https://gitlab.com/oauth/token",
userinfo_url: Some("https://gitlab.com/oauth/userinfo"),
scopes: "openid email profile",
scope_separator: " ",
client_id_param: "client_id",
auth_query_extra: "",
requires_pkce: false,
userinfo_method: UserinfoMethod::Get,
userinfo_parser: UserinfoParser::Oidc,
token_exchange: TokenExchangeShape::Standard,
token_response_json: true,
};
pub static REDDIT: ProviderSpec = ProviderSpec {
id: "reddit",
display_name: "Reddit",
auth_url: "https://www.reddit.com/api/v1/authorize",
token_url: "https://www.reddit.com/api/v1/access_token",
userinfo_url: Some("https://oauth.reddit.com/api/v1/me"),
scopes: "identity",
scope_separator: " ",
client_id_param: "client_id",
auth_query_extra: "",
requires_pkce: false,
userinfo_method: UserinfoMethod::Get,
userinfo_parser: UserinfoParser::Custom {
id_path: "/id",
email_path: "/name",
name_path: Some("/name"),
},
token_exchange: TokenExchangeShape::BasicAuth,
token_response_json: true,
};
pub static NOTION: ProviderSpec = ProviderSpec {
id: "notion",
display_name: "Notion",
auth_url: "https://api.notion.com/v1/oauth/authorize",
token_url: "https://api.notion.com/v1/oauth/token",
userinfo_url: Some("https://api.notion.com/v1/users/me"),
scopes: "",
scope_separator: " ",
client_id_param: "client_id",
auth_query_extra: "owner=user",
requires_pkce: false,
userinfo_method: UserinfoMethod::Get,
userinfo_parser: UserinfoParser::Custom {
id_path: "/bot/owner/user/id",
email_path: "/bot/owner/user/person/email",
name_path: Some("/bot/owner/user/name"),
},
token_exchange: TokenExchangeShape::BasicAuthJsonBody,
token_response_json: true,
};
pub static LINEAR: ProviderSpec = ProviderSpec {
id: "linear",
display_name: "Linear",
auth_url: "https://linear.app/oauth/authorize",
token_url: "https://api.linear.app/oauth/token",
userinfo_url: Some("https://api.linear.app/graphql"),
scopes: "read",
scope_separator: " ",
client_id_param: "client_id",
auth_query_extra: "",
requires_pkce: false,
userinfo_method: UserinfoMethod::Post,
userinfo_parser: UserinfoParser::LinearGraphql,
token_exchange: TokenExchangeShape::Standard,
token_response_json: true,
};
pub static VERCEL: ProviderSpec = ProviderSpec {
id: "vercel",
display_name: "Vercel",
auth_url: "https://vercel.com/oauth/authorize",
token_url: "https://api.vercel.com/v2/oauth/access_token",
userinfo_url: Some("https://api.vercel.com/v2/user"),
scopes: "",
scope_separator: " ",
client_id_param: "client_id",
auth_query_extra: "",
requires_pkce: false,
userinfo_method: UserinfoMethod::Get,
userinfo_parser: UserinfoParser::Custom {
id_path: "/user/id",
email_path: "/user/email",
name_path: Some("/user/name"),
},
token_exchange: TokenExchangeShape::Standard,
token_response_json: true,
};
pub static ZOOM: ProviderSpec = ProviderSpec {
id: "zoom",
display_name: "Zoom",
auth_url: "https://zoom.us/oauth/authorize",
token_url: "https://zoom.us/oauth/token",
userinfo_url: Some("https://api.zoom.us/v2/users/me"),
scopes: "user:read",
scope_separator: " ",
client_id_param: "client_id",
auth_query_extra: "",
requires_pkce: false,
userinfo_method: UserinfoMethod::Get,
userinfo_parser: UserinfoParser::Custom {
id_path: "/id",
email_path: "/email",
name_path: Some("/first_name"),
},
token_exchange: TokenExchangeShape::BasicAuth,
token_response_json: true,
};
pub static SALESFORCE: ProviderSpec = ProviderSpec {
id: "salesforce",
display_name: "Salesforce",
auth_url: "https://login.salesforce.com/services/oauth2/authorize",
token_url: "https://login.salesforce.com/services/oauth2/token",
userinfo_url: Some("https://login.salesforce.com/services/oauth2/userinfo"),
scopes: "openid email profile",
scope_separator: " ",
client_id_param: "client_id",
auth_query_extra: "",
requires_pkce: false,
userinfo_method: UserinfoMethod::Get,
userinfo_parser: UserinfoParser::Oidc,
token_exchange: TokenExchangeShape::Standard,
token_response_json: true,
};
pub static ATLASSIAN: ProviderSpec = ProviderSpec {
id: "atlassian",
display_name: "Atlassian",
auth_url: "https://auth.atlassian.com/authorize",
token_url: "https://auth.atlassian.com/oauth/token",
userinfo_url: Some("https://api.atlassian.com/me"),
scopes: "read:me",
scope_separator: " ",
client_id_param: "client_id",
auth_query_extra: "audience=api.atlassian.com&prompt=consent",
requires_pkce: false,
userinfo_method: UserinfoMethod::Get,
userinfo_parser: UserinfoParser::Custom {
id_path: "/account_id",
email_path: "/email",
name_path: Some("/name"),
},
token_exchange: TokenExchangeShape::JsonBody,
token_response_json: true,
};
pub static FIGMA: ProviderSpec = ProviderSpec {
id: "figma",
display_name: "Figma",
auth_url: "https://www.figma.com/oauth",
token_url: "https://api.figma.com/v1/oauth/token",
userinfo_url: Some("https://api.figma.com/v1/me"),
scopes: "files:read",
scope_separator: " ",
client_id_param: "client_id",
auth_query_extra: "",
requires_pkce: false,
userinfo_method: UserinfoMethod::Get,
userinfo_parser: UserinfoParser::Custom {
id_path: "/id",
email_path: "/email",
name_path: Some("/handle"),
},
token_exchange: TokenExchangeShape::BasicAuth,
token_response_json: true,
};
pub static DROPBOX: ProviderSpec = ProviderSpec {
id: "dropbox",
display_name: "Dropbox",
auth_url: "https://www.dropbox.com/oauth2/authorize",
token_url: "https://api.dropboxapi.com/oauth2/token",
userinfo_url: Some("https://api.dropboxapi.com/2/users/get_current_account"),
scopes: "account_info.read",
scope_separator: " ",
client_id_param: "client_id",
auth_query_extra: "",
requires_pkce: false,
userinfo_method: UserinfoMethod::Post,
userinfo_parser: UserinfoParser::Custom {
id_path: "/account_id",
email_path: "/email",
name_path: Some("/name/display_name"),
},
token_exchange: TokenExchangeShape::Standard,
token_response_json: true,
};
pub static TIKTOK: ProviderSpec = ProviderSpec {
id: "tiktok",
display_name: "TikTok",
auth_url: "https://www.tiktok.com/v2/auth/authorize",
token_url: "https://open.tiktokapis.com/v2/oauth/token/",
userinfo_url: Some(
"https://open.tiktokapis.com/v2/user/info/?fields=open_id,union_id,avatar_url,display_name,username",
),
scopes: "user.info.basic",
scope_separator: ",",
client_id_param: "client_key",
auth_query_extra: "",
requires_pkce: false,
userinfo_method: UserinfoMethod::Get,
userinfo_parser: UserinfoParser::Custom {
id_path: "/data/user/open_id",
email_path: "/data/user/username",
name_path: Some("/data/user/display_name"),
},
token_exchange: TokenExchangeShape::Standard,
token_response_json: true,
};
pub static PAYPAL: ProviderSpec = ProviderSpec {
id: "paypal",
display_name: "PayPal",
auth_url: "https://www.paypal.com/connect",
token_url: "https://api-m.paypal.com/v1/oauth2/token",
userinfo_url: Some(
"https://api-m.paypal.com/v1/identity/openidconnect/userinfo?schema=openid",
),
scopes: "openid email profile",
scope_separator: " ",
client_id_param: "client_id",
auth_query_extra: "",
requires_pkce: false,
userinfo_method: UserinfoMethod::Get,
userinfo_parser: UserinfoParser::Oidc,
token_exchange: TokenExchangeShape::BasicAuth,
token_response_json: true,
};
pub static KICK: ProviderSpec = ProviderSpec {
id: "kick",
display_name: "Kick",
auth_url: "https://id.kick.com/oauth/authorize",
token_url: "https://id.kick.com/oauth/token",
userinfo_url: Some("https://api.kick.com/public/v1/users"),
scopes: "user:read",
scope_separator: " ",
client_id_param: "client_id",
auth_query_extra: "",
requires_pkce: true, userinfo_method: UserinfoMethod::Get,
userinfo_parser: UserinfoParser::Custom {
id_path: "/data/0/user_id",
email_path: "/data/0/email",
name_path: Some("/data/0/name"),
},
token_exchange: TokenExchangeShape::Standard,
token_response_json: true,
};
pub static ROBLOX: ProviderSpec = ProviderSpec {
id: "roblox",
display_name: "Roblox",
auth_url: "https://apis.roblox.com/oauth/v1/authorize",
token_url: "https://apis.roblox.com/oauth/v1/token",
userinfo_url: Some("https://apis.roblox.com/oauth/v1/userinfo"),
scopes: "openid profile",
scope_separator: " ",
client_id_param: "client_id",
auth_query_extra: "",
requires_pkce: false,
userinfo_method: UserinfoMethod::Get,
userinfo_parser: UserinfoParser::Oidc,
token_exchange: TokenExchangeShape::Standard,
token_response_json: true,
};
pub static GENERIC_OIDC: ProviderSpec = ProviderSpec {
id: "oidc",
display_name: "OpenID Connect",
auth_url: "", token_url: "",
userinfo_url: None,
scopes: "openid email profile",
scope_separator: " ",
client_id_param: "client_id",
auth_query_extra: "",
requires_pkce: false,
userinfo_method: UserinfoMethod::Get,
userinfo_parser: UserinfoParser::Oidc,
token_exchange: TokenExchangeShape::Standard,
token_response_json: true,
};
}
pub fn find_spec(id: &str) -> Option<&'static ProviderSpec> {
builtin::all().iter().copied().find(|p| p.id == id)
}
#[derive(Debug, Clone)]
pub enum ResolvedSpec {
Static(&'static ProviderSpec),
Oidc(std::sync::Arc<DiscoveredSpec>),
}
#[derive(Debug, Clone)]
pub struct DiscoveredSpec {
pub auth_url: String,
pub token_url: String,
pub userinfo_url: Option<String>,
pub scopes: String,
pub userinfo_parser: UserinfoParser,
pub token_exchange: TokenExchangeShape,
}
impl ResolvedSpec {
pub fn auth_url(&self) -> &str {
match self {
ResolvedSpec::Static(s) => s.auth_url,
ResolvedSpec::Oidc(d) => &d.auth_url,
}
}
pub fn token_url(&self) -> &str {
match self {
ResolvedSpec::Static(s) => s.token_url,
ResolvedSpec::Oidc(d) => &d.token_url,
}
}
pub fn userinfo_url(&self) -> Option<&str> {
match self {
ResolvedSpec::Static(s) => s.userinfo_url,
ResolvedSpec::Oidc(d) => d.userinfo_url.as_deref(),
}
}
pub fn scopes(&self) -> &str {
match self {
ResolvedSpec::Static(s) => s.scopes,
ResolvedSpec::Oidc(d) => &d.scopes,
}
}
pub fn scope_separator(&self) -> &str {
match self {
ResolvedSpec::Static(s) => s.scope_separator,
ResolvedSpec::Oidc(_) => " ",
}
}
pub fn client_id_param(&self) -> &str {
match self {
ResolvedSpec::Static(s) => s.client_id_param,
ResolvedSpec::Oidc(_) => "client_id",
}
}
pub fn auth_query_extra(&self) -> &str {
match self {
ResolvedSpec::Static(s) => s.auth_query_extra,
ResolvedSpec::Oidc(_) => "",
}
}
pub fn requires_pkce(&self) -> bool {
match self {
ResolvedSpec::Static(s) => s.requires_pkce,
ResolvedSpec::Oidc(_) => false,
}
}
pub fn userinfo_method(&self) -> UserinfoMethod {
match self {
ResolvedSpec::Static(s) => s.userinfo_method,
ResolvedSpec::Oidc(_) => UserinfoMethod::Get,
}
}
pub fn userinfo_parser(&self) -> UserinfoParser {
match self {
ResolvedSpec::Static(s) => s.userinfo_parser.clone(),
ResolvedSpec::Oidc(d) => d.userinfo_parser.clone(),
}
}
pub fn token_exchange(&self) -> TokenExchangeShape {
match self {
ResolvedSpec::Static(s) => s.token_exchange.clone(),
ResolvedSpec::Oidc(d) => d.token_exchange.clone(),
}
}
}
pub fn resolve_endpoint(template: &str, cfg: &ProviderConfig) -> String {
let tenant = cfg.tenant.as_deref().unwrap_or("common");
template.replace("{tenant}", tenant)
}
#[derive(Debug, Clone, Deserialize)]
pub struct OidcDiscoveryDoc {
pub authorization_endpoint: String,
pub token_endpoint: String,
pub userinfo_endpoint: Option<String>,
pub jwks_uri: Option<String>,
pub issuer: String,
#[serde(default)]
pub token_endpoint_auth_methods_supported: Vec<String>,
}
impl OidcDiscoveryDoc {
pub fn parse(json: &str) -> Result<Self, String> {
let doc: Self = serde_json::from_str(json)
.map_err(|e| format!("OIDC discovery doc not valid JSON: {e}"))?;
if doc.authorization_endpoint.is_empty() {
return Err("OIDC discovery doc missing authorization_endpoint".into());
}
if doc.token_endpoint.is_empty() {
return Err("OIDC discovery doc missing token_endpoint".into());
}
Ok(doc)
}
pub fn into_spec(self) -> DiscoveredSpec {
let prefers_post = self
.token_endpoint_auth_methods_supported
.iter()
.any(|m| m == "client_secret_post");
let token_exchange = if prefers_post {
TokenExchangeShape::Standard
} else {
TokenExchangeShape::BasicAuth
};
DiscoveredSpec {
auth_url: self.authorization_endpoint,
token_url: self.token_endpoint,
userinfo_url: self.userinfo_endpoint,
scopes: "openid email profile".to_string(),
userinfo_parser: UserinfoParser::Oidc,
token_exchange,
}
}
}
pub mod oidc_cache {
use super::*;
use std::sync::{Arc, Mutex, OnceLock};
type Cache = Mutex<std::collections::HashMap<String, Arc<DiscoveredSpec>>>;
fn cache() -> &'static Cache {
static CACHE: OnceLock<Cache> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
}
pub fn resolve(issuer: &str) -> Result<ResolvedSpec, String> {
if let Some(spec) = cache().lock().unwrap().get(issuer) {
return Ok(ResolvedSpec::Oidc(spec.clone()));
}
let url = if issuer.ends_with('/') {
format!("{issuer}.well-known/openid-configuration")
} else {
format!("{issuer}/.well-known/openid-configuration")
};
let agent = ureq::AgentBuilder::new()
.timeout_connect(std::time::Duration::from_secs(10))
.timeout_read(std::time::Duration::from_secs(10))
.build();
let body = agent
.get(&url)
.call()
.map_err(|e| format!("oidc discovery {url}: {e}"))?
.into_string()
.map_err(|e| format!("oidc discovery body {url}: {e}"))?;
let doc = OidcDiscoveryDoc::parse(&body)?;
let spec = Arc::new(doc.into_spec());
cache()
.lock()
.unwrap()
.insert(issuer.to_string(), spec.clone());
Ok(ResolvedSpec::Oidc(spec))
}
#[cfg(test)]
pub fn insert_for_test(issuer: &str, spec: DiscoveredSpec) {
cache()
.lock()
.unwrap()
.insert(issuer.to_string(), Arc::new(spec));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_builtin_has_unique_id() {
let mut seen = std::collections::HashSet::new();
for spec in builtin::all() {
assert!(
seen.insert(spec.id),
"duplicate provider id in builtin::all: {}",
spec.id
);
}
}
#[test]
fn every_builtin_has_nonempty_endpoints() {
for spec in builtin::all() {
assert!(!spec.auth_url.is_empty(), "{}: missing auth_url", spec.id);
assert!(!spec.token_url.is_empty(), "{}: missing token_url", spec.id);
assert!(
!spec.scopes.is_empty() || spec.id == "notion" || spec.id == "vercel",
"{}: empty scopes (only Notion/Vercel are allowed empty)",
spec.id
);
}
}
#[test]
fn find_spec_returns_known_providers() {
assert!(find_spec("google").is_some());
assert!(find_spec("github").is_some());
assert!(find_spec("apple").is_some());
assert!(find_spec("microsoft").is_some());
assert!(find_spec("nonexistent").is_none());
}
#[test]
fn resolve_endpoint_substitutes_tenant() {
let cfg = ProviderConfig {
provider: "microsoft".into(),
client_id: "x".into(),
client_secret: "y".into(),
redirect_uri: "z".into(),
scopes_override: None,
tenant: Some("contoso.onmicrosoft.com".into()),
apple: None,
oidc_issuer: None,
};
let resolved = resolve_endpoint(
"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize",
&cfg,
);
assert_eq!(
resolved,
"https://login.microsoftonline.com/contoso.onmicrosoft.com/oauth2/v2.0/authorize"
);
}
#[test]
fn resolve_endpoint_defaults_tenant_to_common() {
let cfg = ProviderConfig {
provider: "microsoft".into(),
client_id: "x".into(),
client_secret: "y".into(),
redirect_uri: "z".into(),
scopes_override: None,
tenant: None,
apple: None,
oidc_issuer: None,
};
let resolved = resolve_endpoint(
"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize",
&cfg,
);
assert!(resolved.contains("/common/"));
}
#[test]
fn oidc_discovery_doc_parses_minimal() {
let json = r#"{
"issuer": "https://acme.auth0.com/",
"authorization_endpoint": "https://acme.auth0.com/authorize",
"token_endpoint": "https://acme.auth0.com/oauth/token",
"userinfo_endpoint": "https://acme.auth0.com/userinfo",
"jwks_uri": "https://acme.auth0.com/.well-known/jwks.json"
}"#;
let doc = OidcDiscoveryDoc::parse(json).expect("parse");
assert_eq!(doc.issuer, "https://acme.auth0.com/");
assert_eq!(
doc.authorization_endpoint,
"https://acme.auth0.com/authorize"
);
assert_eq!(doc.token_endpoint, "https://acme.auth0.com/oauth/token");
assert_eq!(
doc.userinfo_endpoint.as_deref(),
Some("https://acme.auth0.com/userinfo")
);
}
}