99import org .bson .BsonString ;
1010import org .restheart .plugins .accounts .AccountsConfigData ;
1111import org .restheart .accounts .util .RequestOverrides ;
12+ import org .restheart .exchange .ServiceRequest ;
1213import org .restheart .plugins .Inject ;
1314import org .restheart .plugins .OnInit ;
1415import org .restheart .plugins .PluginRecord ;
@@ -89,7 +90,7 @@ public class OAuthService implements Provider<OAuthService>, OAuthProviderRegist
8990
9091 // ── Helpers ───────────────────────────────────────────────────────────────
9192
92- private static String queryParam (org . restheart . exchange . ServiceRequest <?> req , String name ) {
93+ private static String queryParam (ServiceRequest <?> req , String name ) {
9394 if (req == null ) return null ;
9495 var values = req .getQueryParameters ().get (name );
9596 return (values != null && !values .isEmpty ()) ? values .getFirst () : null ;
@@ -133,24 +134,24 @@ public void registerProvider(OAuthProvider provider) {
133134 * @return an {@link AuthResult} with the authorization redirect URL and the CSRF state
134135 * @throws OAuthException if the provider is not configured or not registered
135136 */
136- public AuthResult getAuthorizationUrl (String providerName ,
137- org .restheart .exchange .ServiceRequest <?> req ) throws OAuthException {
137+ public AuthResult getAuthorizationUrl (String providerName , ServiceRequest <?> req ) throws OAuthException {
138138 var cfg = resolveProviderConfig (providerName , req );
139- var provider = resolveProvider (providerName );
140- var teamDb = RequestOverrides .db (req , accountsConf );
141- var state = generateState (teamDb );
142- var pendingInviteToken = queryParam (req , "pendingInviteToken" );
143- var consentsAccepted = "true" .equalsIgnoreCase (queryParam (req , "consentsAccepted" ));
139+ var provider = resolveProvider (providerName );
140+ var teamDb = RequestOverrides .db (req , accountsConf );
141+ var apiBaseUrl = RequestOverrides .oauthApiBaseUrl (req , config );
142+ var state = generateState (teamDb );
143+ var pendingInviteToken = queryParam (req , "pendingInviteToken" );
144+ var consentsAccepted = "true" .equalsIgnoreCase (queryParam (req , "consentsAccepted" ));
144145
145146 storeStateToken (state , providerName , teamDb , pendingInviteToken , consentsAccepted );
146147
147148 var url = provider .getAuthorizationUrl (cfg .clientId (), cfg .clientSecret (),
148- config .callbackUrl (providerName ), cfg .scope (), state );
149+ config .callbackUrl (providerName , apiBaseUrl ), cfg .scope (), state );
149150 return new AuthResult (url , state );
150151 }
151152
152153 /**
153- * @deprecated Use {@link #getAuthorizationUrl(String, org.restheart.exchange. ServiceRequest)} instead.
154+ * @deprecated Use {@link #getAuthorizationUrl(String, ServiceRequest)} instead.
154155 * @param providerName the OAuth provider name
155156 * @throws OAuthException if the provider is not configured or not registered
156157 */
@@ -169,25 +170,32 @@ public AuthResult getAuthorizationUrl(String providerName) throws OAuthException
169170 * @param providerName the OAuth provider name extracted from the callback path
170171 * @param code the authorization code received from the OAuth provider
171172 * @param state the CSRF state token returned by the provider (must match a stored token)
173+ * @param req the incoming callback request, used to resolve per-team overrides;
174+ * may be {@code null} for non-HTTP use (falls back to static config)
172175 * @return a {@link CallbackResult} carrying the user profile and the invite
173176 * context that was stored in the state token at authorization time
174177 * @throws OAuthException if the state token is invalid/expired, the provider is
175178 * not registered, or the profile fetch fails
176179 */
177- public CallbackResult handleCallback (String providerName , String code , String state )
180+ public CallbackResult handleCallback (String providerName , String code , String state , ServiceRequest <?> req )
178181 throws OAuthException {
179182
180183 var token = verifyAndConsumeState (state , providerName );
181184 if (token == null ) {
182185 throw new OAuthException ("Invalid or expired state token (possible CSRF)" );
183186 }
184187
185- var cfg = resolveProviderConfig (providerName );
186- var provider = resolveProvider (providerName );
188+ // Must resolve with the SAME overrides used to build the authorize URL — a
189+ // per-team override changes both clientId/clientSecret and, via apiBaseUrl below,
190+ // the callbackUrl passed to the provider; the provider validates that the callback
191+ // token exchange uses the exact same callbackUrl/credentials as the authorize step.
192+ var cfg = resolveProviderConfig (providerName , req );
193+ var provider = resolveProvider (providerName );
194+ var apiBaseUrl = RequestOverrides .oauthApiBaseUrl (req , config );
187195
188196 try {
189197 var profile = provider .fetchUserProfile (cfg .clientId (), cfg .clientSecret (),
190- config .callbackUrl (providerName ), cfg .scope (), code );
198+ config .callbackUrl (providerName , apiBaseUrl ), cfg .scope (), code );
191199 return new CallbackResult (profile , token .pendingInviteToken (), token .consentsAccepted ());
192200 } catch (OAuthException e ) {
193201 throw e ;
@@ -282,25 +290,42 @@ private OAuthConfig.ProviderConfig resolveProviderConfig(String name) throws OAu
282290 }
283291
284292 /**
285- * Resolves provider config, checking per-team overrides before falling
286- * back to static config (currently only Google supports overrides) .
293+ * Resolves provider config, checking per-team overrides before falling back to
294+ * static config. Provider-agnostic — works for any provider name, not just Google .
287295 *
288- * @param name provider name (case-insensitive), e.g. {@code "google"}
296+ * @param name provider name (case-insensitive), e.g. {@code "google"}, {@code "github"}
289297 * @param req the incoming request used to read per-team overrides; may be {@code null}
290298 * @return the effective {@link OAuthConfig.ProviderConfig}
291299 * @throws OAuthException if OAuth is disabled or the provider is not configured
292300 */
293- public OAuthConfig .ProviderConfig resolveProviderConfig (String name ,
294- org . restheart . exchange . ServiceRequest <?> req ) throws OAuthException {
295- if ("google" . equalsIgnoreCase ( name ) && req != null ) {
296- var teamCfg = org . restheart . accounts . util . RequestOverrides .oauthGoogle (req );
301+ public OAuthConfig .ProviderConfig resolveProviderConfig (String name , ServiceRequest <?> req )
302+ throws OAuthException {
303+ if (req != null ) {
304+ var teamCfg = RequestOverrides .oauthProvider (req , name , config );
297305 if (teamCfg != null && teamCfg .isValid ()) {
298306 return teamCfg ;
299307 }
300308 }
301309 return resolveProviderConfig (name );
302310 }
303311
312+ /**
313+ * Whether {@code name} is usable for this request — either via a per-team override
314+ * ({@link RequestOverrides#oauthProvider}) or the static config. Use this instead of
315+ * {@link OAuthConfig#isProviderEnabled(String)} whenever a request is available: the
316+ * static-only check rejects providers that are configured <em>exclusively</em> via
317+ * per-team overrides (e.g. a multi-tenant node with no {@code providers.{name}} YAML
318+ * entry at all, by design — see {@code RequestOverrides} class docs).
319+ */
320+ public boolean isProviderAvailable (String name , ServiceRequest <?> req ) {
321+ try {
322+ resolveProviderConfig (name , req );
323+ return true ;
324+ } catch (OAuthException e ) {
325+ return false ;
326+ }
327+ }
328+
304329 private OAuthProvider resolveProvider (String name ) throws OAuthException {
305330 var p = providers .get (name .toLowerCase ());
306331 if (p == null ) throw new OAuthException ("Provider '" + name + "' is not registered" );
0 commit comments