1use serde::{Deserialize, Serialize};
30
31#[derive(Debug, Clone)]
40pub struct ProviderSpec {
41 pub id: &'static str,
44
45 pub display_name: &'static str,
47
48 pub auth_url: &'static str,
51
52 pub token_url: &'static str,
55
56 pub userinfo_url: Option<&'static str>,
60
61 pub scopes: &'static str,
64
65 pub scope_separator: &'static str,
67
68 pub client_id_param: &'static str,
71
72 pub auth_query_extra: &'static str,
76
77 pub requires_pkce: bool,
82
83 pub userinfo_method: UserinfoMethod,
86
87 pub userinfo_parser: UserinfoParser,
92
93 pub token_exchange: TokenExchangeShape,
95
96 pub token_response_json: bool,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum UserinfoMethod {
107 Get,
108 Post,
109}
110
111#[derive(Debug, Clone)]
113pub enum UserinfoParser {
114 Oidc,
116
117 GitHub,
120
121 LinearGraphql,
123
124 AppleIdToken,
127
128 Custom {
132 id_path: &'static str,
133 email_path: &'static str,
134 name_path: Option<&'static str>,
135 },
136}
137
138#[derive(Debug, Clone)]
140pub enum TokenExchangeShape {
141 Standard,
145
146 AppleJwt,
150
151 BasicAuth,
157
158 JsonBody,
162
163 BasicAuthJsonBody,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct ProviderConfig {
179 pub provider: String,
180 pub client_id: String,
181 pub client_secret: String,
186 pub redirect_uri: String,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub scopes_override: Option<String>,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub tenant: Option<String>,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub apple: Option<AppleConfig>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub oidc_issuer: Option<String>,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct AppleConfig {
214 pub team_id: String,
216 pub key_id: String,
218 pub private_key_pem: String,
221}
222
223pub mod builtin {
228 use super::*;
229
230 pub fn all() -> &'static [&'static ProviderSpec] {
233 ALL
234 }
235
236 static ALL: &[&ProviderSpec] = &[
239 &GOOGLE,
240 &GITHUB,
241 &APPLE,
242 &MICROSOFT,
243 &DISCORD,
244 &SLACK,
245 &SPOTIFY,
246 &TWITCH,
247 &TWITTER,
248 &LINKEDIN,
249 &FACEBOOK,
250 &GITLAB,
251 &REDDIT,
252 &NOTION,
253 &LINEAR,
254 &VERCEL,
255 &ZOOM,
256 &SALESFORCE,
257 &ATLASSIAN,
258 &FIGMA,
259 &DROPBOX,
260 &TIKTOK,
261 &PAYPAL,
262 &KICK,
263 &ROBLOX,
264 ];
265
266 pub static GOOGLE: ProviderSpec = ProviderSpec {
267 id: "google",
268 display_name: "Google",
269 auth_url: "https://accounts.google.com/o/oauth2/v2/auth",
270 token_url: "https://oauth2.googleapis.com/token",
271 userinfo_url: Some("https://www.googleapis.com/oauth2/v3/userinfo"),
272 scopes: "openid email profile",
273 scope_separator: " ",
274 client_id_param: "client_id",
275 auth_query_extra: "",
276 requires_pkce: false,
277 userinfo_method: UserinfoMethod::Get,
278 userinfo_parser: UserinfoParser::Oidc,
279 token_exchange: TokenExchangeShape::Standard,
280 token_response_json: true,
281 };
282
283 pub static GITHUB: ProviderSpec = ProviderSpec {
284 id: "github",
285 display_name: "GitHub",
286 auth_url: "https://github.com/login/oauth/authorize",
287 token_url: "https://github.com/login/oauth/access_token",
288 userinfo_url: Some("https://api.github.com/user"),
289 scopes: "user:email",
290 scope_separator: " ",
291 client_id_param: "client_id",
292 auth_query_extra: "",
293 requires_pkce: false,
294 userinfo_method: UserinfoMethod::Get,
295 userinfo_parser: UserinfoParser::GitHub,
296 token_exchange: TokenExchangeShape::Standard,
297 token_response_json: true,
298 };
299
300 pub static APPLE: ProviderSpec = ProviderSpec {
307 id: "apple",
308 display_name: "Apple",
309 auth_url: "https://appleid.apple.com/auth/authorize",
310 token_url: "https://appleid.apple.com/auth/token",
311 userinfo_url: None,
314 scopes: "name email",
315 scope_separator: " ",
316 client_id_param: "client_id",
317 auth_query_extra: "response_mode=form_post",
318 requires_pkce: false,
319 userinfo_method: UserinfoMethod::Get,
320 userinfo_parser: UserinfoParser::AppleIdToken,
321 token_exchange: TokenExchangeShape::AppleJwt,
322 token_response_json: true,
323 };
324
325 pub static MICROSOFT: ProviderSpec = ProviderSpec {
329 id: "microsoft",
330 display_name: "Microsoft",
331 auth_url: "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize",
332 token_url: "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token",
333 userinfo_url: Some("https://graph.microsoft.com/oidc/userinfo"),
334 scopes: "openid email profile",
335 scope_separator: " ",
336 client_id_param: "client_id",
337 auth_query_extra: "",
338 requires_pkce: false,
339 userinfo_method: UserinfoMethod::Get,
340 userinfo_parser: UserinfoParser::Oidc,
341 token_exchange: TokenExchangeShape::Standard,
342 token_response_json: true,
343 };
344
345 pub static DISCORD: ProviderSpec = ProviderSpec {
346 id: "discord",
347 display_name: "Discord",
348 auth_url: "https://discord.com/oauth2/authorize",
349 token_url: "https://discord.com/api/oauth2/token",
350 userinfo_url: Some("https://discord.com/api/users/@me"),
351 scopes: "identify email",
352 scope_separator: " ",
353 client_id_param: "client_id",
354 auth_query_extra: "",
355 requires_pkce: false,
356 userinfo_method: UserinfoMethod::Get,
357 userinfo_parser: UserinfoParser::Custom {
358 id_path: "/id",
359 email_path: "/email",
360 name_path: Some("/global_name"),
361 },
362 token_exchange: TokenExchangeShape::Standard,
363 token_response_json: true,
364 };
365
366 pub static SLACK: ProviderSpec = ProviderSpec {
367 id: "slack",
368 display_name: "Slack",
369 auth_url: "https://slack.com/openid/connect/authorize",
370 token_url: "https://slack.com/api/openid.connect.token",
371 userinfo_url: Some("https://slack.com/api/openid.connect.userInfo"),
372 scopes: "openid email profile",
373 scope_separator: " ",
374 client_id_param: "client_id",
375 auth_query_extra: "",
376 requires_pkce: false,
377 userinfo_method: UserinfoMethod::Get,
378 userinfo_parser: UserinfoParser::Oidc,
379 token_exchange: TokenExchangeShape::Standard,
380 token_response_json: true,
381 };
382
383 pub static SPOTIFY: ProviderSpec = ProviderSpec {
384 id: "spotify",
385 display_name: "Spotify",
386 auth_url: "https://accounts.spotify.com/authorize",
387 token_url: "https://accounts.spotify.com/api/token",
388 userinfo_url: Some("https://api.spotify.com/v1/me"),
389 scopes: "user-read-email user-read-private",
390 scope_separator: " ",
391 client_id_param: "client_id",
392 auth_query_extra: "",
393 requires_pkce: false,
394 userinfo_method: UserinfoMethod::Get,
395 userinfo_parser: UserinfoParser::Custom {
396 id_path: "/id",
397 email_path: "/email",
398 name_path: Some("/display_name"),
399 },
400 token_exchange: TokenExchangeShape::BasicAuth,
401 token_response_json: true,
402 };
403
404 pub static TWITCH: ProviderSpec = ProviderSpec {
405 id: "twitch",
406 display_name: "Twitch",
407 auth_url: "https://id.twitch.tv/oauth2/authorize",
408 token_url: "https://id.twitch.tv/oauth2/token",
409 userinfo_url: Some("https://id.twitch.tv/oauth2/userinfo"),
410 scopes: "openid user:read:email",
411 scope_separator: " ",
412 client_id_param: "client_id",
413 auth_query_extra: "",
414 requires_pkce: false,
415 userinfo_method: UserinfoMethod::Get,
416 userinfo_parser: UserinfoParser::Oidc,
417 token_exchange: TokenExchangeShape::Standard,
418 token_response_json: true,
419 };
420
421 pub static TWITTER: ProviderSpec = ProviderSpec {
429 id: "twitter",
430 display_name: "Twitter / X",
431 auth_url: "https://twitter.com/i/oauth2/authorize",
432 token_url: "https://api.twitter.com/2/oauth2/token",
433 userinfo_url: Some("https://api.twitter.com/2/users/me?user.fields=id,name,username"),
434 scopes: "users.read tweet.read",
435 scope_separator: " ",
436 client_id_param: "client_id",
437 auth_query_extra: "",
438 requires_pkce: true,
439 userinfo_method: UserinfoMethod::Get,
440 userinfo_parser: UserinfoParser::Custom {
441 id_path: "/data/id",
442 email_path: "/data/username",
443 name_path: Some("/data/name"),
444 },
445 token_exchange: TokenExchangeShape::BasicAuth,
446 token_response_json: true,
447 };
448
449 pub static LINKEDIN: ProviderSpec = ProviderSpec {
450 id: "linkedin",
451 display_name: "LinkedIn",
452 auth_url: "https://www.linkedin.com/oauth/v2/authorization",
453 token_url: "https://www.linkedin.com/oauth/v2/accessToken",
454 userinfo_url: Some("https://api.linkedin.com/v2/userinfo"),
455 scopes: "openid profile email",
456 scope_separator: " ",
457 client_id_param: "client_id",
458 auth_query_extra: "",
459 requires_pkce: false,
460 userinfo_method: UserinfoMethod::Get,
461 userinfo_parser: UserinfoParser::Oidc,
462 token_exchange: TokenExchangeShape::Standard,
463 token_response_json: true,
464 };
465
466 pub static FACEBOOK: ProviderSpec = ProviderSpec {
467 id: "facebook",
468 display_name: "Facebook",
469 auth_url: "https://www.facebook.com/v18.0/dialog/oauth",
470 token_url: "https://graph.facebook.com/v18.0/oauth/access_token",
471 userinfo_url: Some("https://graph.facebook.com/me?fields=id,email,name"),
472 scopes: "email public_profile",
473 scope_separator: " ",
474 client_id_param: "client_id",
475 auth_query_extra: "",
476 requires_pkce: false,
477 userinfo_method: UserinfoMethod::Get,
478 userinfo_parser: UserinfoParser::Custom {
479 id_path: "/id",
480 email_path: "/email",
481 name_path: Some("/name"),
482 },
483 token_exchange: TokenExchangeShape::Standard,
484 token_response_json: true,
485 };
486
487 pub static GITLAB: ProviderSpec = ProviderSpec {
488 id: "gitlab",
489 display_name: "GitLab",
490 auth_url: "https://gitlab.com/oauth/authorize",
491 token_url: "https://gitlab.com/oauth/token",
492 userinfo_url: Some("https://gitlab.com/oauth/userinfo"),
493 scopes: "openid email profile",
494 scope_separator: " ",
495 client_id_param: "client_id",
496 auth_query_extra: "",
497 requires_pkce: false,
498 userinfo_method: UserinfoMethod::Get,
499 userinfo_parser: UserinfoParser::Oidc,
500 token_exchange: TokenExchangeShape::Standard,
501 token_response_json: true,
502 };
503
504 pub static REDDIT: ProviderSpec = ProviderSpec {
505 id: "reddit",
506 display_name: "Reddit",
507 auth_url: "https://www.reddit.com/api/v1/authorize",
508 token_url: "https://www.reddit.com/api/v1/access_token",
509 userinfo_url: Some("https://oauth.reddit.com/api/v1/me"),
510 scopes: "identity",
511 scope_separator: " ",
512 client_id_param: "client_id",
513 auth_query_extra: "",
514 requires_pkce: false,
515 userinfo_method: UserinfoMethod::Get,
516 userinfo_parser: UserinfoParser::Custom {
517 id_path: "/id",
518 email_path: "/name",
522 name_path: Some("/name"),
523 },
524 token_exchange: TokenExchangeShape::BasicAuth,
525 token_response_json: true,
526 };
527
528 pub static NOTION: ProviderSpec = ProviderSpec {
531 id: "notion",
532 display_name: "Notion",
533 auth_url: "https://api.notion.com/v1/oauth/authorize",
534 token_url: "https://api.notion.com/v1/oauth/token",
535 userinfo_url: Some("https://api.notion.com/v1/users/me"),
536 scopes: "",
537 scope_separator: " ",
538 client_id_param: "client_id",
539 auth_query_extra: "owner=user",
540 requires_pkce: false,
541 userinfo_method: UserinfoMethod::Get,
542 userinfo_parser: UserinfoParser::Custom {
543 id_path: "/bot/owner/user/id",
544 email_path: "/bot/owner/user/person/email",
545 name_path: Some("/bot/owner/user/name"),
546 },
547 token_exchange: TokenExchangeShape::BasicAuthJsonBody,
548 token_response_json: true,
549 };
550
551 pub static LINEAR: ProviderSpec = ProviderSpec {
552 id: "linear",
553 display_name: "Linear",
554 auth_url: "https://linear.app/oauth/authorize",
555 token_url: "https://api.linear.app/oauth/token",
556 userinfo_url: Some("https://api.linear.app/graphql"),
559 scopes: "read",
560 scope_separator: " ",
561 client_id_param: "client_id",
562 auth_query_extra: "",
563 requires_pkce: false,
564 userinfo_method: UserinfoMethod::Post,
565 userinfo_parser: UserinfoParser::LinearGraphql,
566 token_exchange: TokenExchangeShape::Standard,
567 token_response_json: true,
568 };
569
570 pub static VERCEL: ProviderSpec = ProviderSpec {
571 id: "vercel",
572 display_name: "Vercel",
573 auth_url: "https://vercel.com/oauth/authorize",
574 token_url: "https://api.vercel.com/v2/oauth/access_token",
575 userinfo_url: Some("https://api.vercel.com/v2/user"),
576 scopes: "",
577 scope_separator: " ",
578 client_id_param: "client_id",
579 auth_query_extra: "",
580 requires_pkce: false,
581 userinfo_method: UserinfoMethod::Get,
582 userinfo_parser: UserinfoParser::Custom {
583 id_path: "/user/id",
584 email_path: "/user/email",
585 name_path: Some("/user/name"),
586 },
587 token_exchange: TokenExchangeShape::Standard,
588 token_response_json: true,
589 };
590
591 pub static ZOOM: ProviderSpec = ProviderSpec {
592 id: "zoom",
593 display_name: "Zoom",
594 auth_url: "https://zoom.us/oauth/authorize",
595 token_url: "https://zoom.us/oauth/token",
596 userinfo_url: Some("https://api.zoom.us/v2/users/me"),
597 scopes: "user:read",
598 scope_separator: " ",
599 client_id_param: "client_id",
600 auth_query_extra: "",
601 requires_pkce: false,
602 userinfo_method: UserinfoMethod::Get,
603 userinfo_parser: UserinfoParser::Custom {
604 id_path: "/id",
605 email_path: "/email",
606 name_path: Some("/first_name"),
607 },
608 token_exchange: TokenExchangeShape::BasicAuth,
609 token_response_json: true,
610 };
611
612 pub static SALESFORCE: ProviderSpec = ProviderSpec {
613 id: "salesforce",
614 display_name: "Salesforce",
615 auth_url: "https://login.salesforce.com/services/oauth2/authorize",
616 token_url: "https://login.salesforce.com/services/oauth2/token",
617 userinfo_url: Some("https://login.salesforce.com/services/oauth2/userinfo"),
618 scopes: "openid email profile",
619 scope_separator: " ",
620 client_id_param: "client_id",
621 auth_query_extra: "",
622 requires_pkce: false,
623 userinfo_method: UserinfoMethod::Get,
624 userinfo_parser: UserinfoParser::Oidc,
625 token_exchange: TokenExchangeShape::Standard,
626 token_response_json: true,
627 };
628
629 pub static ATLASSIAN: ProviderSpec = ProviderSpec {
632 id: "atlassian",
633 display_name: "Atlassian",
634 auth_url: "https://auth.atlassian.com/authorize",
635 token_url: "https://auth.atlassian.com/oauth/token",
636 userinfo_url: Some("https://api.atlassian.com/me"),
637 scopes: "read:me",
638 scope_separator: " ",
639 client_id_param: "client_id",
640 auth_query_extra: "audience=api.atlassian.com&prompt=consent",
641 requires_pkce: false,
642 userinfo_method: UserinfoMethod::Get,
643 userinfo_parser: UserinfoParser::Custom {
644 id_path: "/account_id",
645 email_path: "/email",
646 name_path: Some("/name"),
647 },
648 token_exchange: TokenExchangeShape::JsonBody,
649 token_response_json: true,
650 };
651
652 pub static FIGMA: ProviderSpec = ProviderSpec {
653 id: "figma",
654 display_name: "Figma",
655 auth_url: "https://www.figma.com/oauth",
656 token_url: "https://api.figma.com/v1/oauth/token",
657 userinfo_url: Some("https://api.figma.com/v1/me"),
658 scopes: "files:read",
659 scope_separator: " ",
660 client_id_param: "client_id",
661 auth_query_extra: "",
662 requires_pkce: false,
663 userinfo_method: UserinfoMethod::Get,
664 userinfo_parser: UserinfoParser::Custom {
665 id_path: "/id",
666 email_path: "/email",
667 name_path: Some("/handle"),
668 },
669 token_exchange: TokenExchangeShape::BasicAuth,
670 token_response_json: true,
671 };
672
673 pub static DROPBOX: ProviderSpec = ProviderSpec {
676 id: "dropbox",
677 display_name: "Dropbox",
678 auth_url: "https://www.dropbox.com/oauth2/authorize",
679 token_url: "https://api.dropboxapi.com/oauth2/token",
680 userinfo_url: Some("https://api.dropboxapi.com/2/users/get_current_account"),
681 scopes: "account_info.read",
682 scope_separator: " ",
683 client_id_param: "client_id",
684 auth_query_extra: "",
685 requires_pkce: false,
686 userinfo_method: UserinfoMethod::Post,
687 userinfo_parser: UserinfoParser::Custom {
688 id_path: "/account_id",
689 email_path: "/email",
690 name_path: Some("/name/display_name"),
691 },
692 token_exchange: TokenExchangeShape::Standard,
693 token_response_json: true,
694 };
695
696 pub static TIKTOK: ProviderSpec = ProviderSpec {
699 id: "tiktok",
700 display_name: "TikTok",
701 auth_url: "https://www.tiktok.com/v2/auth/authorize",
702 token_url: "https://open.tiktokapis.com/v2/oauth/token/",
703 userinfo_url: Some(
704 "https://open.tiktokapis.com/v2/user/info/?fields=open_id,union_id,avatar_url,display_name,username",
705 ),
706 scopes: "user.info.basic",
707 scope_separator: ",",
708 client_id_param: "client_key",
709 auth_query_extra: "",
710 requires_pkce: false,
711 userinfo_method: UserinfoMethod::Get,
712 userinfo_parser: UserinfoParser::Custom {
713 id_path: "/data/user/open_id",
714 email_path: "/data/user/username",
715 name_path: Some("/data/user/display_name"),
716 },
717 token_exchange: TokenExchangeShape::Standard,
718 token_response_json: true,
719 };
720
721 pub static PAYPAL: ProviderSpec = ProviderSpec {
722 id: "paypal",
723 display_name: "PayPal",
724 auth_url: "https://www.paypal.com/connect",
725 token_url: "https://api-m.paypal.com/v1/oauth2/token",
726 userinfo_url: Some("https://api-m.paypal.com/v1/identity/openidconnect/userinfo?schema=openid"),
727 scopes: "openid email profile",
728 scope_separator: " ",
729 client_id_param: "client_id",
730 auth_query_extra: "",
731 requires_pkce: false,
732 userinfo_method: UserinfoMethod::Get,
733 userinfo_parser: UserinfoParser::Oidc,
734 token_exchange: TokenExchangeShape::BasicAuth,
735 token_response_json: true,
736 };
737
738 pub static KICK: ProviderSpec = ProviderSpec {
739 id: "kick",
740 display_name: "Kick",
741 auth_url: "https://id.kick.com/oauth/authorize",
742 token_url: "https://id.kick.com/oauth/token",
743 userinfo_url: Some("https://api.kick.com/public/v1/users"),
744 scopes: "user:read",
745 scope_separator: " ",
746 client_id_param: "client_id",
747 auth_query_extra: "",
748 requires_pkce: true, userinfo_method: UserinfoMethod::Get,
750 userinfo_parser: UserinfoParser::Custom {
751 id_path: "/data/0/user_id",
752 email_path: "/data/0/email",
753 name_path: Some("/data/0/name"),
754 },
755 token_exchange: TokenExchangeShape::Standard,
756 token_response_json: true,
757 };
758
759 pub static ROBLOX: ProviderSpec = ProviderSpec {
760 id: "roblox",
761 display_name: "Roblox",
762 auth_url: "https://apis.roblox.com/oauth/v1/authorize",
763 token_url: "https://apis.roblox.com/oauth/v1/token",
764 userinfo_url: Some("https://apis.roblox.com/oauth/v1/userinfo"),
765 scopes: "openid profile",
766 scope_separator: " ",
767 client_id_param: "client_id",
768 auth_query_extra: "",
769 requires_pkce: false,
770 userinfo_method: UserinfoMethod::Get,
771 userinfo_parser: UserinfoParser::Oidc,
772 token_exchange: TokenExchangeShape::Standard,
773 token_response_json: true,
774 };
775
776 pub static GENERIC_OIDC: ProviderSpec = ProviderSpec {
782 id: "oidc",
783 display_name: "OpenID Connect",
784 auth_url: "", token_url: "",
786 userinfo_url: None,
787 scopes: "openid email profile",
788 scope_separator: " ",
789 client_id_param: "client_id",
790 auth_query_extra: "",
791 requires_pkce: false,
792 userinfo_method: UserinfoMethod::Get,
793 userinfo_parser: UserinfoParser::Oidc,
794 token_exchange: TokenExchangeShape::Standard,
795 token_response_json: true,
796 };
797}
798
799pub fn find_spec(id: &str) -> Option<&'static ProviderSpec> {
803 builtin::all().iter().copied().find(|p| p.id == id)
804}
805
806#[derive(Debug, Clone)]
810pub enum ResolvedSpec {
811 Static(&'static ProviderSpec),
813 Oidc(std::sync::Arc<DiscoveredSpec>),
815}
816
817#[derive(Debug, Clone)]
821pub struct DiscoveredSpec {
822 pub auth_url: String,
823 pub token_url: String,
824 pub userinfo_url: Option<String>,
825 pub scopes: String,
826 pub userinfo_parser: UserinfoParser,
827 pub token_exchange: TokenExchangeShape,
828}
829
830impl ResolvedSpec {
831 pub fn auth_url(&self) -> &str {
832 match self {
833 ResolvedSpec::Static(s) => s.auth_url,
834 ResolvedSpec::Oidc(d) => &d.auth_url,
835 }
836 }
837 pub fn token_url(&self) -> &str {
838 match self {
839 ResolvedSpec::Static(s) => s.token_url,
840 ResolvedSpec::Oidc(d) => &d.token_url,
841 }
842 }
843 pub fn userinfo_url(&self) -> Option<&str> {
844 match self {
845 ResolvedSpec::Static(s) => s.userinfo_url,
846 ResolvedSpec::Oidc(d) => d.userinfo_url.as_deref(),
847 }
848 }
849 pub fn scopes(&self) -> &str {
850 match self {
851 ResolvedSpec::Static(s) => s.scopes,
852 ResolvedSpec::Oidc(d) => &d.scopes,
853 }
854 }
855 pub fn scope_separator(&self) -> &str {
856 match self {
857 ResolvedSpec::Static(s) => s.scope_separator,
858 ResolvedSpec::Oidc(_) => " ",
859 }
860 }
861 pub fn client_id_param(&self) -> &str {
862 match self {
863 ResolvedSpec::Static(s) => s.client_id_param,
864 ResolvedSpec::Oidc(_) => "client_id",
865 }
866 }
867 pub fn auth_query_extra(&self) -> &str {
868 match self {
869 ResolvedSpec::Static(s) => s.auth_query_extra,
870 ResolvedSpec::Oidc(_) => "",
871 }
872 }
873 pub fn requires_pkce(&self) -> bool {
874 match self {
875 ResolvedSpec::Static(s) => s.requires_pkce,
876 ResolvedSpec::Oidc(_) => false,
877 }
878 }
879 pub fn userinfo_method(&self) -> UserinfoMethod {
880 match self {
881 ResolvedSpec::Static(s) => s.userinfo_method,
882 ResolvedSpec::Oidc(_) => UserinfoMethod::Get,
883 }
884 }
885 pub fn userinfo_parser(&self) -> UserinfoParser {
886 match self {
887 ResolvedSpec::Static(s) => s.userinfo_parser.clone(),
888 ResolvedSpec::Oidc(d) => d.userinfo_parser.clone(),
889 }
890 }
891 pub fn token_exchange(&self) -> TokenExchangeShape {
892 match self {
893 ResolvedSpec::Static(s) => s.token_exchange.clone(),
894 ResolvedSpec::Oidc(d) => d.token_exchange.clone(),
895 }
896 }
897}
898
899pub fn resolve_endpoint(template: &str, cfg: &ProviderConfig) -> String {
904 let tenant = cfg.tenant.as_deref().unwrap_or("common");
905 template.replace("{tenant}", tenant)
906}
907
908#[derive(Debug, Clone, Deserialize)]
916pub struct OidcDiscoveryDoc {
917 pub authorization_endpoint: String,
918 pub token_endpoint: String,
919 pub userinfo_endpoint: Option<String>,
920 pub jwks_uri: Option<String>,
921 pub issuer: String,
922 #[serde(default)]
927 pub token_endpoint_auth_methods_supported: Vec<String>,
928}
929
930impl OidcDiscoveryDoc {
931 pub fn parse(json: &str) -> Result<Self, String> {
934 let doc: Self = serde_json::from_str(json)
935 .map_err(|e| format!("OIDC discovery doc not valid JSON: {e}"))?;
936 if doc.authorization_endpoint.is_empty() {
937 return Err("OIDC discovery doc missing authorization_endpoint".into());
938 }
939 if doc.token_endpoint.is_empty() {
940 return Err("OIDC discovery doc missing token_endpoint".into());
941 }
942 Ok(doc)
943 }
944
945 pub fn into_spec(self) -> DiscoveredSpec {
952 let prefers_post = self
953 .token_endpoint_auth_methods_supported
954 .iter()
955 .any(|m| m == "client_secret_post");
956 let token_exchange = if prefers_post {
957 TokenExchangeShape::Standard
958 } else {
959 TokenExchangeShape::BasicAuth
960 };
961 DiscoveredSpec {
962 auth_url: self.authorization_endpoint,
963 token_url: self.token_endpoint,
964 userinfo_url: self.userinfo_endpoint,
965 scopes: "openid email profile".to_string(),
966 userinfo_parser: UserinfoParser::Oidc,
967 token_exchange,
968 }
969 }
970}
971
972pub mod oidc_cache {
978 use super::*;
979 use std::sync::{Arc, Mutex, OnceLock};
980
981 type Cache = Mutex<std::collections::HashMap<String, Arc<DiscoveredSpec>>>;
982 fn cache() -> &'static Cache {
983 static CACHE: OnceLock<Cache> = OnceLock::new();
984 CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
985 }
986
987 pub fn resolve(issuer: &str) -> Result<ResolvedSpec, String> {
993 if let Some(spec) = cache().lock().unwrap().get(issuer) {
994 return Ok(ResolvedSpec::Oidc(spec.clone()));
995 }
996 let url = if issuer.ends_with('/') {
997 format!("{issuer}.well-known/openid-configuration")
998 } else {
999 format!("{issuer}/.well-known/openid-configuration")
1000 };
1001 let agent = ureq::AgentBuilder::new()
1002 .timeout_connect(std::time::Duration::from_secs(10))
1003 .timeout_read(std::time::Duration::from_secs(10))
1004 .build();
1005 let body = agent
1006 .get(&url)
1007 .call()
1008 .map_err(|e| format!("oidc discovery {url}: {e}"))?
1009 .into_string()
1010 .map_err(|e| format!("oidc discovery body {url}: {e}"))?;
1011 let doc = OidcDiscoveryDoc::parse(&body)?;
1012 let spec = Arc::new(doc.into_spec());
1013 cache()
1014 .lock()
1015 .unwrap()
1016 .insert(issuer.to_string(), spec.clone());
1017 Ok(ResolvedSpec::Oidc(spec))
1018 }
1019
1020 #[cfg(test)]
1023 pub fn insert_for_test(issuer: &str, spec: DiscoveredSpec) {
1024 cache()
1025 .lock()
1026 .unwrap()
1027 .insert(issuer.to_string(), Arc::new(spec));
1028 }
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033 use super::*;
1034
1035 #[test]
1036 fn every_builtin_has_unique_id() {
1037 let mut seen = std::collections::HashSet::new();
1038 for spec in builtin::all() {
1039 assert!(
1040 seen.insert(spec.id),
1041 "duplicate provider id in builtin::all: {}",
1042 spec.id
1043 );
1044 }
1045 }
1046
1047 #[test]
1048 fn every_builtin_has_nonempty_endpoints() {
1049 for spec in builtin::all() {
1050 assert!(!spec.auth_url.is_empty(), "{}: missing auth_url", spec.id);
1051 assert!(!spec.token_url.is_empty(), "{}: missing token_url", spec.id);
1052 assert!(!spec.scopes.is_empty() || spec.id == "notion" || spec.id == "vercel",
1053 "{}: empty scopes (only Notion/Vercel are allowed empty)", spec.id);
1054 }
1055 }
1056
1057 #[test]
1058 fn find_spec_returns_known_providers() {
1059 assert!(find_spec("google").is_some());
1060 assert!(find_spec("github").is_some());
1061 assert!(find_spec("apple").is_some());
1062 assert!(find_spec("microsoft").is_some());
1063 assert!(find_spec("nonexistent").is_none());
1064 }
1065
1066 #[test]
1067 fn resolve_endpoint_substitutes_tenant() {
1068 let cfg = ProviderConfig {
1069 provider: "microsoft".into(),
1070 client_id: "x".into(),
1071 client_secret: "y".into(),
1072 redirect_uri: "z".into(),
1073 scopes_override: None,
1074 tenant: Some("contoso.onmicrosoft.com".into()),
1075 apple: None,
1076 oidc_issuer: None,
1077 };
1078 let resolved = resolve_endpoint(
1079 "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize",
1080 &cfg,
1081 );
1082 assert_eq!(
1083 resolved,
1084 "https://login.microsoftonline.com/contoso.onmicrosoft.com/oauth2/v2.0/authorize"
1085 );
1086 }
1087
1088 #[test]
1089 fn resolve_endpoint_defaults_tenant_to_common() {
1090 let cfg = ProviderConfig {
1091 provider: "microsoft".into(),
1092 client_id: "x".into(),
1093 client_secret: "y".into(),
1094 redirect_uri: "z".into(),
1095 scopes_override: None,
1096 tenant: None,
1097 apple: None,
1098 oidc_issuer: None,
1099 };
1100 let resolved = resolve_endpoint(
1101 "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize",
1102 &cfg,
1103 );
1104 assert!(resolved.contains("/common/"));
1105 }
1106
1107 #[test]
1108 fn oidc_discovery_doc_parses_minimal() {
1109 let json = r#"{
1110 "issuer": "https://acme.auth0.com/",
1111 "authorization_endpoint": "https://acme.auth0.com/authorize",
1112 "token_endpoint": "https://acme.auth0.com/oauth/token",
1113 "userinfo_endpoint": "https://acme.auth0.com/userinfo",
1114 "jwks_uri": "https://acme.auth0.com/.well-known/jwks.json"
1115 }"#;
1116 let doc = OidcDiscoveryDoc::parse(json).expect("parse");
1117 assert_eq!(doc.issuer, "https://acme.auth0.com/");
1118 assert_eq!(doc.authorization_endpoint, "https://acme.auth0.com/authorize");
1119 assert_eq!(doc.token_endpoint, "https://acme.auth0.com/oauth/token");
1120 assert_eq!(
1121 doc.userinfo_endpoint.as_deref(),
1122 Some("https://acme.auth0.com/userinfo")
1123 );
1124 }
1125}