Skip to main content

pylon_auth/
provider.rs

1//! Table-driven OAuth/OIDC provider registry.
2//!
3//! Replaces the per-provider `match self.provider.as_str()` arms in
4//! `lib.rs::OAuthConfig` with a single `ProviderSpec` struct that
5//! fully describes a provider's endpoints and how to parse its
6//! userinfo response. Adding a new provider becomes a struct literal
7//! in [`builtin::all`] — no new branches anywhere else.
8//!
9//! Two flavors of provider are supported:
10//!
11//! - **Static specs** (Google, GitHub, Apple, Discord, Slack, etc.) —
12//!   endpoints + userinfo shape are hard-coded in [`builtin::all`].
13//!   Adding a 51st provider that follows the standard OAuth2/OIDC
14//!   shape is one struct literal.
15//!
16//! - **OIDC discovery** (`from_issuer`) — pulls
17//!   `<issuer>/.well-known/openid-configuration` and synthesizes a
18//!   spec at runtime. Covers Auth0, Okta, Cognito, Keycloak, Logto,
19//!   Authentik, Zitadel, and any compliant OIDC IdP without code
20//!   changes. The runtime caches the discovery response so we don't
21//!   round-trip the IdP on every login.
22//!
23//! Provider-specific quirks — Apple's RS256-signed `client_secret`,
24//! GitHub's "primary email lives at /user/emails", Microsoft's
25//! tenant-aware endpoints — are carried as enum variants on
26//! [`ClientSecret`] and [`UserinfoSource`] so the call sites stay
27//! data-driven.
28
29use serde::{Deserialize, Serialize};
30
31// ---------------------------------------------------------------------------
32// ProviderSpec — the full description of one OAuth provider
33// ---------------------------------------------------------------------------
34
35/// Static description of one OAuth/OIDC provider. Endpoint URLs are
36/// formatted with `{tenant}` etc. placeholders that the spec resolves
37/// when given a runtime config (e.g. Microsoft swaps `{tenant}` for
38/// the configured Azure tenant id).
39#[derive(Debug, Clone)]
40pub struct ProviderSpec {
41    /// Stable id used in the dashboard URL and the `Account.provider`
42    /// column. Lowercase ASCII; matches `/api/auth/login/<id>`.
43    pub id: &'static str,
44
45    /// Human-readable name for buttons / UIs.
46    pub display_name: &'static str,
47
48    /// Authorization endpoint — where we send the user to grant access.
49    /// May contain `{tenant}` for tenant-aware providers (Microsoft).
50    pub auth_url: &'static str,
51
52    /// Token exchange endpoint — POST'd with the auth code to get
53    /// access + refresh tokens.
54    pub token_url: &'static str,
55
56    /// Userinfo endpoint — GET'd with the access token to pull the
57    /// authed user's profile. `None` for providers that put the
58    /// identity inside the `id_token` JWT only (Apple).
59    pub userinfo_url: Option<&'static str>,
60
61    /// OAuth scope string the spec asks for. Defaults to the minimum
62    /// needed to ID the user. Separator is [`Self::scope_separator`].
63    pub scopes: &'static str,
64
65    /// Scope separator. RFC 6749 says space; TikTok uses comma.
66    pub scope_separator: &'static str,
67
68    /// Form-field name for the OAuth `client_id`. RFC 6749 says
69    /// `client_id`; TikTok says `client_key`.
70    pub client_id_param: &'static str,
71
72    /// Extra query parameters appended to the auth URL (already
73    /// URL-encoded, no leading `&`). Apple needs `response_mode=form_post`
74    /// when name/email scopes are requested; this is the hook for it.
75    pub auth_query_extra: &'static str,
76
77    /// PKCE — when true, pylon generates `code_verifier` /
78    /// `code_challenge` (SHA-256, S256), sends the challenge on the
79    /// auth request, and replays the verifier on token exchange.
80    /// Twitter/X *requires* it; Google/Microsoft *recommend* it.
81    pub requires_pkce: bool,
82
83    /// HTTP method used for the userinfo fetch. Most providers use
84    /// GET; Dropbox uses POST.
85    pub userinfo_method: UserinfoMethod,
86
87    /// How to extract `(provider_account_id, email, display_name)`
88    /// from the provider's userinfo response. Provider-stable id
89    /// path (Google's `sub`, GitHub's `id`) is what `Account` keys
90    /// on, NOT the email — a renamed-email user keeps their account.
91    pub userinfo_parser: UserinfoParser,
92
93    /// Provider-specific oddities for token exchange.
94    pub token_exchange: TokenExchangeShape,
95
96    /// Whether the token endpoint expects an `Accept: application/json`
97    /// header (required for GitHub's classic OAuth, otherwise it
98    /// returns form-urlencoded). Default true; flip false for
99    /// providers that explicitly require form encoding.
100    pub token_response_json: bool,
101}
102
103/// Userinfo fetch HTTP verb. Dropbox uses POST with an empty body;
104/// every other supported provider uses GET.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum UserinfoMethod {
107    Get,
108    Post,
109}
110
111/// Where + how to read identity fields out of a userinfo response.
112#[derive(Debug, Clone)]
113pub enum UserinfoParser {
114    /// Standard OIDC shape: `{ sub, email, name, picture }`.
115    Oidc,
116
117    /// GitHub's REST shape — `id` is numeric, name falls back to
118    /// `login`, email may need a separate /user/emails fetch.
119    GitHub,
120
121    /// Linear's `{ viewer: { id, email, name } }` GraphQL response.
122    LinearGraphql,
123
124    /// Apple — identity lives in the `id_token` JWT returned by the
125    /// token endpoint, not a userinfo endpoint. Decoded inline.
126    AppleIdToken,
127
128    /// Custom JSON pointers — for one-off providers whose responses
129    /// don't match any standard shape. JSON-pointer paths into the
130    /// response object.
131    Custom {
132        id_path: &'static str,
133        email_path: &'static str,
134        name_path: Option<&'static str>,
135    },
136}
137
138/// Provider-specific token exchange request shape.
139#[derive(Debug, Clone)]
140pub enum TokenExchangeShape {
141    /// Standard `grant_type=authorization_code&...` form body with the
142    /// client_id + client_secret embedded as form fields. Covers
143    /// Google, GitHub, Discord, Slack, Spotify, and most providers.
144    Standard,
145
146    /// Apple: `client_secret` is a JWT signed with the developer's
147    /// ES256 private key (NOT a static string). The key id and team
148    /// id are needed to mint it. See [`apple_jwt`] for the signer.
149    AppleJwt,
150
151    /// HTTP Basic auth instead of form fields for client_id /
152    /// client_secret. Body still carries
153    /// `grant_type=authorization_code&code=…&redirect_uri=…`.
154    /// OIDC's default per the discovery spec when
155    /// `token_endpoint_auth_methods_supported` is omitted.
156    BasicAuth,
157
158    /// JSON body — `{ "grant_type": "authorization_code", "code": …,
159    /// "redirect_uri": …, "client_id": …, "client_secret": … }`.
160    /// Atlassian 3LO requires this.
161    JsonBody,
162
163    /// JSON body + HTTP Basic auth header. Notion uses this:
164    /// the body carries `grant_type` + `code` + `redirect_uri`,
165    /// the credentials live in the Authorization header.
166    BasicAuthJsonBody,
167}
168
169// ---------------------------------------------------------------------------
170// Runtime config + lookup
171// ---------------------------------------------------------------------------
172
173/// Runtime config layered on top of a static [`ProviderSpec`]: the
174/// developer's client_id/client_secret/redirect_uri, plus any
175/// per-provider extras (Microsoft tenant id, Apple key material,
176/// scopes override).
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct ProviderConfig {
179    pub provider: String,
180    pub client_id: String,
181    /// For most providers this is a static string. For Apple, it's
182    /// the path to (or PEM contents of) a private key — see
183    /// [`AppleConfig`]. The runtime stores this opaquely and the
184    /// signer is responsible for interpretation.
185    pub client_secret: String,
186    pub redirect_uri: String,
187    /// Scopes override — when present, replaces [`ProviderSpec::scopes`].
188    /// Use cases: requesting `repo` on GitHub for app-installation
189    /// flows; requesting `https://www.googleapis.com/auth/calendar` on
190    /// Google for app-specific data access.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub scopes_override: Option<String>,
193    /// Tenant id for Microsoft / Entra. Defaults to `common` (any
194    /// account type — work, school, personal). Single-tenant apps
195    /// supply a directory GUID; multi-tenant work-only apps use
196    /// `organizations`.
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub tenant: Option<String>,
199    /// Apple-specific extras. None for non-Apple providers.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub apple: Option<AppleConfig>,
202    /// OIDC issuer URL for [`builtin::generic_oidc`]-style providers.
203    /// When set, the discovery cache pulls
204    /// `<issuer>/.well-known/openid-configuration` to populate
205    /// the spec at first use.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub oidc_issuer: Option<String>,
208}
209
210/// Apple-specific config. See
211/// <https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens>.
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct AppleConfig {
214    /// Apple Developer team id (10-char alphanumeric).
215    pub team_id: String,
216    /// Key id from Apple Developer portal (10-char alphanumeric).
217    pub key_id: String,
218    /// PEM-encoded ES256 private key. Either the key contents inline
219    /// or the file path — the signer detects which.
220    pub private_key_pem: String,
221}
222
223// ---------------------------------------------------------------------------
224// Builtin specs — adding a provider is one entry here
225// ---------------------------------------------------------------------------
226
227pub mod builtin {
228    use super::*;
229
230    /// All compile-time-known providers. Lookup table for
231    /// [`super::find_spec`]. Order is irrelevant; matched by `id`.
232    pub fn all() -> &'static [&'static ProviderSpec] {
233        ALL
234    }
235
236    /// Static array — taking `&` of a slice expression returns a
237    /// temporary, so we hoist it to a `static` and return a borrow.
238    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    /// Apple — uses `name` in scope to get the user's name on
301    /// first-time signup (Apple ONLY returns name on the first
302    /// authorization; subsequent logins don't include it).
303    /// `response_mode=form_post` is REQUIRED by Apple when name/email
304    /// scopes are requested — Apple POSTs the callback to the
305    /// redirect URL with `code` + `id_token` in the form body.
306    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        // No standalone userinfo endpoint — identity comes from the
312        // id_token JWT in the token response. Decoded inline.
313        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    /// Microsoft / Entra ID. The auth + token URLs include
326    /// `{tenant}` which is resolved against `ProviderConfig.tenant`
327    /// at request build time. Defaults to `common` (any account).
328    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    /// Twitter / X. OAuth 2.0 PKCE-only — pylon generates
422    /// code_verifier/challenge and stores them in the OAuth state
423    /// record alongside the redirect URLs. Twitter's userinfo
424    /// doesn't include email without an extra approval; users with
425    /// email-disabled accounts fall back to `<username>@x.invalid`
426    /// (caller decides whether to accept). Returned `id` is the
427    /// Twitter snowflake.
428    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            // Reddit doesn't expose email — fall back to a synthesized
519            // username@reddit.invalid so account-store still has a
520            // value. Apps that require a real email should reject.
521            email_path: "/name",
522            name_path: Some("/name"),
523        },
524        token_exchange: TokenExchangeShape::BasicAuth,
525        token_response_json: true,
526    };
527
528    /// Notion uses Basic auth + JSON body for token exchange (per
529    /// their docs at https://developers.notion.com/guides/get-started/authorization).
530    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        // Linear is GraphQL only; we POST a fixed query at request
557        // time. The fetcher special-cases this id (see runtime layer).
558        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    /// Atlassian 3LO uses JSON body for token exchange. Without the
630    /// JSON content type they reject with a parser error.
631    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    /// Dropbox userinfo is a POST RPC endpoint with an empty body
674    /// — they don't follow the GET-userinfo convention.
675    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    /// TikTok deviates from RFC 6749: form field is `client_key`
697    /// (not `client_id`) and scopes are comma-separated.
698    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(
727            "https://api-m.paypal.com/v1/identity/openidconnect/userinfo?schema=openid",
728        ),
729        scopes: "openid email profile",
730        scope_separator: " ",
731        client_id_param: "client_id",
732        auth_query_extra: "",
733        requires_pkce: false,
734        userinfo_method: UserinfoMethod::Get,
735        userinfo_parser: UserinfoParser::Oidc,
736        token_exchange: TokenExchangeShape::BasicAuth,
737        token_response_json: true,
738    };
739
740    pub static KICK: ProviderSpec = ProviderSpec {
741        id: "kick",
742        display_name: "Kick",
743        auth_url: "https://id.kick.com/oauth/authorize",
744        token_url: "https://id.kick.com/oauth/token",
745        userinfo_url: Some("https://api.kick.com/public/v1/users"),
746        scopes: "user:read",
747        scope_separator: " ",
748        client_id_param: "client_id",
749        auth_query_extra: "",
750        requires_pkce: true, // Kick requires PKCE per their docs
751        userinfo_method: UserinfoMethod::Get,
752        userinfo_parser: UserinfoParser::Custom {
753            id_path: "/data/0/user_id",
754            email_path: "/data/0/email",
755            name_path: Some("/data/0/name"),
756        },
757        token_exchange: TokenExchangeShape::Standard,
758        token_response_json: true,
759    };
760
761    pub static ROBLOX: ProviderSpec = ProviderSpec {
762        id: "roblox",
763        display_name: "Roblox",
764        auth_url: "https://apis.roblox.com/oauth/v1/authorize",
765        token_url: "https://apis.roblox.com/oauth/v1/token",
766        userinfo_url: Some("https://apis.roblox.com/oauth/v1/userinfo"),
767        scopes: "openid profile",
768        scope_separator: " ",
769        client_id_param: "client_id",
770        auth_query_extra: "",
771        requires_pkce: false,
772        userinfo_method: UserinfoMethod::Get,
773        userinfo_parser: UserinfoParser::Oidc,
774        token_exchange: TokenExchangeShape::Standard,
775        token_response_json: true,
776    };
777
778    /// Generic OIDC stub. Real specs are produced at runtime by
779    /// fetching `<issuer>/.well-known/openid-configuration` and
780    /// using the discovered URLs. The static stub exists so a
781    /// `ProviderConfig` with `oidc_issuer = "https://acme.auth0.com"`
782    /// has something to point its `provider` field at.
783    pub static GENERIC_OIDC: ProviderSpec = ProviderSpec {
784        id: "oidc",
785        display_name: "OpenID Connect",
786        auth_url: "", // resolved from discovery doc
787        token_url: "",
788        userinfo_url: None,
789        scopes: "openid email profile",
790        scope_separator: " ",
791        client_id_param: "client_id",
792        auth_query_extra: "",
793        requires_pkce: false,
794        userinfo_method: UserinfoMethod::Get,
795        userinfo_parser: UserinfoParser::Oidc,
796        token_exchange: TokenExchangeShape::Standard,
797        token_response_json: true,
798    };
799}
800
801/// Look up a static provider spec by id. Returns `None` for unknown
802/// ids OR for OIDC issuer-config providers (those need
803/// [`oidc_cache::resolve`] to materialize a runtime spec).
804pub fn find_spec(id: &str) -> Option<&'static ProviderSpec> {
805    builtin::all().iter().copied().find(|p| p.id == id)
806}
807
808/// Either a compile-time builtin spec or a runtime-discovered OIDC
809/// spec. The two cases share read accessors via this enum so call
810/// sites don't care where the URLs came from.
811#[derive(Debug, Clone)]
812pub enum ResolvedSpec {
813    /// Static spec from [`builtin::all`].
814    Static(&'static ProviderSpec),
815    /// Runtime spec materialized from an OIDC discovery doc.
816    Oidc(std::sync::Arc<DiscoveredSpec>),
817}
818
819/// Owned spec produced by OIDC discovery — same fields as
820/// [`ProviderSpec`] but with `String` instead of `&'static str` since
821/// the URLs come from the network, not the binary.
822#[derive(Debug, Clone)]
823pub struct DiscoveredSpec {
824    pub auth_url: String,
825    pub token_url: String,
826    pub userinfo_url: Option<String>,
827    pub scopes: String,
828    pub userinfo_parser: UserinfoParser,
829    pub token_exchange: TokenExchangeShape,
830}
831
832impl ResolvedSpec {
833    pub fn auth_url(&self) -> &str {
834        match self {
835            ResolvedSpec::Static(s) => s.auth_url,
836            ResolvedSpec::Oidc(d) => &d.auth_url,
837        }
838    }
839    pub fn token_url(&self) -> &str {
840        match self {
841            ResolvedSpec::Static(s) => s.token_url,
842            ResolvedSpec::Oidc(d) => &d.token_url,
843        }
844    }
845    pub fn userinfo_url(&self) -> Option<&str> {
846        match self {
847            ResolvedSpec::Static(s) => s.userinfo_url,
848            ResolvedSpec::Oidc(d) => d.userinfo_url.as_deref(),
849        }
850    }
851    pub fn scopes(&self) -> &str {
852        match self {
853            ResolvedSpec::Static(s) => s.scopes,
854            ResolvedSpec::Oidc(d) => &d.scopes,
855        }
856    }
857    pub fn scope_separator(&self) -> &str {
858        match self {
859            ResolvedSpec::Static(s) => s.scope_separator,
860            ResolvedSpec::Oidc(_) => " ",
861        }
862    }
863    pub fn client_id_param(&self) -> &str {
864        match self {
865            ResolvedSpec::Static(s) => s.client_id_param,
866            ResolvedSpec::Oidc(_) => "client_id",
867        }
868    }
869    pub fn auth_query_extra(&self) -> &str {
870        match self {
871            ResolvedSpec::Static(s) => s.auth_query_extra,
872            ResolvedSpec::Oidc(_) => "",
873        }
874    }
875    pub fn requires_pkce(&self) -> bool {
876        match self {
877            ResolvedSpec::Static(s) => s.requires_pkce,
878            ResolvedSpec::Oidc(_) => false,
879        }
880    }
881    pub fn userinfo_method(&self) -> UserinfoMethod {
882        match self {
883            ResolvedSpec::Static(s) => s.userinfo_method,
884            ResolvedSpec::Oidc(_) => UserinfoMethod::Get,
885        }
886    }
887    pub fn userinfo_parser(&self) -> UserinfoParser {
888        match self {
889            ResolvedSpec::Static(s) => s.userinfo_parser.clone(),
890            ResolvedSpec::Oidc(d) => d.userinfo_parser.clone(),
891        }
892    }
893    pub fn token_exchange(&self) -> TokenExchangeShape {
894        match self {
895            ResolvedSpec::Static(s) => s.token_exchange.clone(),
896            ResolvedSpec::Oidc(d) => d.token_exchange.clone(),
897        }
898    }
899}
900
901/// Resolve `{tenant}` placeholders in an endpoint URL using the
902/// runtime config. Today only Microsoft uses this, but the
903/// substitution is generic so future tenant-aware providers don't
904/// need new code.
905pub fn resolve_endpoint(template: &str, cfg: &ProviderConfig) -> String {
906    let tenant = cfg.tenant.as_deref().unwrap_or("common");
907    template.replace("{tenant}", tenant)
908}
909
910// ---------------------------------------------------------------------------
911// OIDC discovery (runtime — produces a synthesized ProviderSpec)
912// ---------------------------------------------------------------------------
913
914/// Fields we extract from an OIDC provider's
915/// `/.well-known/openid-configuration` document. Everything else is
916/// ignored — pylon's auth flow only uses these.
917#[derive(Debug, Clone, Deserialize)]
918pub struct OidcDiscoveryDoc {
919    pub authorization_endpoint: String,
920    pub token_endpoint: String,
921    pub userinfo_endpoint: Option<String>,
922    pub jwks_uri: Option<String>,
923    pub issuer: String,
924    /// Per OIDC Discovery: when present, lists the auth methods the
925    /// token endpoint accepts. When omitted the OIDC default is
926    /// `client_secret_basic` (NOT `client_secret_post`!) — getting
927    /// this wrong silently breaks every IdP that follows the spec.
928    #[serde(default)]
929    pub token_endpoint_auth_methods_supported: Vec<String>,
930}
931
932impl OidcDiscoveryDoc {
933    /// Parse a discovery JSON blob. Returns the relevant fields or
934    /// an error if the doc is missing required endpoints.
935    pub fn parse(json: &str) -> Result<Self, String> {
936        let doc: Self = serde_json::from_str(json)
937            .map_err(|e| format!("OIDC discovery doc not valid JSON: {e}"))?;
938        if doc.authorization_endpoint.is_empty() {
939            return Err("OIDC discovery doc missing authorization_endpoint".into());
940        }
941        if doc.token_endpoint.is_empty() {
942            return Err("OIDC discovery doc missing token_endpoint".into());
943        }
944        Ok(doc)
945    }
946
947    /// Convert into a runtime [`DiscoveredSpec`] using OIDC-standard
948    /// scopes + parser. Token-exchange shape is selected from the
949    /// discovered `token_endpoint_auth_methods_supported`:
950    ///   - `client_secret_post` → [`TokenExchangeShape::Standard`]
951    ///   - everything else (including the spec default of
952    ///     `client_secret_basic`) → [`TokenExchangeShape::BasicAuth`]
953    pub fn into_spec(self) -> DiscoveredSpec {
954        let prefers_post = self
955            .token_endpoint_auth_methods_supported
956            .iter()
957            .any(|m| m == "client_secret_post");
958        let token_exchange = if prefers_post {
959            TokenExchangeShape::Standard
960        } else {
961            TokenExchangeShape::BasicAuth
962        };
963        DiscoveredSpec {
964            auth_url: self.authorization_endpoint,
965            token_url: self.token_endpoint,
966            userinfo_url: self.userinfo_endpoint,
967            scopes: "openid email profile".to_string(),
968            userinfo_parser: UserinfoParser::Oidc,
969            token_exchange,
970        }
971    }
972}
973
974/// Process-wide cache of OIDC discovery documents. The cache is
975/// populated lazily on first use of an `oidc_issuer`-configured
976/// provider and never invalidated — the discovery doc is meant to
977/// be stable for the lifetime of the process. If the IdP changes
978/// endpoints (rare), restart the server.
979pub mod oidc_cache {
980    use super::*;
981    use std::sync::{Arc, Mutex, OnceLock};
982
983    type Cache = Mutex<std::collections::HashMap<String, Arc<DiscoveredSpec>>>;
984    fn cache() -> &'static Cache {
985        static CACHE: OnceLock<Cache> = OnceLock::new();
986        CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
987    }
988
989    /// Resolve an issuer URL into a `ResolvedSpec`. On cache miss
990    /// fetches `<issuer>/.well-known/openid-configuration` over HTTPS
991    /// and parses it. The cache key is the issuer URL exactly as
992    /// supplied — pylon does NOT canonicalize trailing slashes so the
993    /// caller controls cache keying.
994    pub fn resolve(issuer: &str) -> Result<ResolvedSpec, String> {
995        if let Some(spec) = cache().lock().unwrap().get(issuer) {
996            return Ok(ResolvedSpec::Oidc(spec.clone()));
997        }
998        let url = if issuer.ends_with('/') {
999            format!("{issuer}.well-known/openid-configuration")
1000        } else {
1001            format!("{issuer}/.well-known/openid-configuration")
1002        };
1003        let agent = ureq::AgentBuilder::new()
1004            .timeout_connect(std::time::Duration::from_secs(10))
1005            .timeout_read(std::time::Duration::from_secs(10))
1006            .build();
1007        let body = agent
1008            .get(&url)
1009            .call()
1010            .map_err(|e| format!("oidc discovery {url}: {e}"))?
1011            .into_string()
1012            .map_err(|e| format!("oidc discovery body {url}: {e}"))?;
1013        let doc = OidcDiscoveryDoc::parse(&body)?;
1014        let spec = Arc::new(doc.into_spec());
1015        cache()
1016            .lock()
1017            .unwrap()
1018            .insert(issuer.to_string(), spec.clone());
1019        Ok(ResolvedSpec::Oidc(spec))
1020    }
1021
1022    /// Test-only helper: prime the cache with a synthetic spec so
1023    /// unit tests don't need network access.
1024    #[cfg(test)]
1025    pub fn insert_for_test(issuer: &str, spec: DiscoveredSpec) {
1026        cache()
1027            .lock()
1028            .unwrap()
1029            .insert(issuer.to_string(), Arc::new(spec));
1030    }
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035    use super::*;
1036
1037    #[test]
1038    fn every_builtin_has_unique_id() {
1039        let mut seen = std::collections::HashSet::new();
1040        for spec in builtin::all() {
1041            assert!(
1042                seen.insert(spec.id),
1043                "duplicate provider id in builtin::all: {}",
1044                spec.id
1045            );
1046        }
1047    }
1048
1049    #[test]
1050    fn every_builtin_has_nonempty_endpoints() {
1051        for spec in builtin::all() {
1052            assert!(!spec.auth_url.is_empty(), "{}: missing auth_url", spec.id);
1053            assert!(!spec.token_url.is_empty(), "{}: missing token_url", spec.id);
1054            assert!(
1055                !spec.scopes.is_empty() || spec.id == "notion" || spec.id == "vercel",
1056                "{}: empty scopes (only Notion/Vercel are allowed empty)",
1057                spec.id
1058            );
1059        }
1060    }
1061
1062    #[test]
1063    fn find_spec_returns_known_providers() {
1064        assert!(find_spec("google").is_some());
1065        assert!(find_spec("github").is_some());
1066        assert!(find_spec("apple").is_some());
1067        assert!(find_spec("microsoft").is_some());
1068        assert!(find_spec("nonexistent").is_none());
1069    }
1070
1071    #[test]
1072    fn resolve_endpoint_substitutes_tenant() {
1073        let cfg = ProviderConfig {
1074            provider: "microsoft".into(),
1075            client_id: "x".into(),
1076            client_secret: "y".into(),
1077            redirect_uri: "z".into(),
1078            scopes_override: None,
1079            tenant: Some("contoso.onmicrosoft.com".into()),
1080            apple: None,
1081            oidc_issuer: None,
1082        };
1083        let resolved = resolve_endpoint(
1084            "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize",
1085            &cfg,
1086        );
1087        assert_eq!(
1088            resolved,
1089            "https://login.microsoftonline.com/contoso.onmicrosoft.com/oauth2/v2.0/authorize"
1090        );
1091    }
1092
1093    #[test]
1094    fn resolve_endpoint_defaults_tenant_to_common() {
1095        let cfg = ProviderConfig {
1096            provider: "microsoft".into(),
1097            client_id: "x".into(),
1098            client_secret: "y".into(),
1099            redirect_uri: "z".into(),
1100            scopes_override: None,
1101            tenant: None,
1102            apple: None,
1103            oidc_issuer: None,
1104        };
1105        let resolved = resolve_endpoint(
1106            "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize",
1107            &cfg,
1108        );
1109        assert!(resolved.contains("/common/"));
1110    }
1111
1112    #[test]
1113    fn oidc_discovery_doc_parses_minimal() {
1114        let json = r#"{
1115            "issuer": "https://acme.auth0.com/",
1116            "authorization_endpoint": "https://acme.auth0.com/authorize",
1117            "token_endpoint": "https://acme.auth0.com/oauth/token",
1118            "userinfo_endpoint": "https://acme.auth0.com/userinfo",
1119            "jwks_uri": "https://acme.auth0.com/.well-known/jwks.json"
1120        }"#;
1121        let doc = OidcDiscoveryDoc::parse(json).expect("parse");
1122        assert_eq!(doc.issuer, "https://acme.auth0.com/");
1123        assert_eq!(
1124            doc.authorization_endpoint,
1125            "https://acme.auth0.com/authorize"
1126        );
1127        assert_eq!(doc.token_endpoint, "https://acme.auth0.com/oauth/token");
1128        assert_eq!(
1129            doc.userinfo_endpoint.as_deref(),
1130            Some("https://acme.auth0.com/userinfo")
1131        );
1132    }
1133}