Skip to content

Commit 1a0dca8

Browse files
authored
Merge pull request #11693 from neinteractiveliterature/login-troubleshooting-part-2
Fix white-screen-on-login (cross-origin OIDC discovery), plus third-party OAuth sign-in and refresh diagnostics
2 parents d2484a6 + c85f06e commit 1a0dca8

22 files changed

Lines changed: 430 additions & 44 deletions

.rubocop_todo.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,12 @@ Metrics/BlockLength:
5050
- 'config/environments/production.rb'
5151
- 'config/initializers/doorkeeper.rb'
5252

53-
# Offense count: 4
53+
# Offense count: 5
5454
# Configuration parameters: CountComments, Max, CountAsOne.
5555
Metrics/ClassLength:
5656
Exclude:
5757
- 'app/controllers/application_controller.rb'
58+
- 'app/controllers/oauth_sessions_controller.rb'
5859
- 'app/graphql/types/ability_type.rb'
5960
- 'app/graphql/types/mutation_type.rb'
6061
- 'app/graphql/types/query_type.rb'

app/controllers/client_configuration_controller.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ def show
1414
render json: {
1515
oauth_frontend_application_uid: Doorkeeper::Application.find_by(is_intercode_frontend: true)&.uid,
1616
oidc_issuer_url: oidc_issuer_url,
17+
# The SPA used to fetch these via the OIDC discovery document, but that's a
18+
# cross-origin request to the issuer host (a convention page reaching the root
19+
# site), which gets blocked (Brave shields, untrusted dev certs, etc.) and
20+
# leaves login hanging. Serving them here — same-origin — removes that
21+
# dependency. Built from the issuer URL so they point at the issuer host
22+
# rather than whatever convention host is making this request.
23+
oidc_authorization_endpoint: oidc_authorization_endpoint,
24+
oidc_end_session_endpoint: oidc_end_session_endpoint,
1725
rails_default_active_storage_service_name: Rails.application.config.active_storage.service.to_s,
1826
rails_direct_uploads_url: rails_direct_uploads_url,
1927
recaptcha_site_key: Recaptcha.configuration.site_key,
@@ -22,4 +30,14 @@ def show
2230
rollbar_client_access_token: ENV["ROLLBAR_CLIENT_ACCESS_TOKEN"].presence
2331
}
2432
end
33+
34+
private
35+
36+
def oidc_authorization_endpoint
37+
URI.join(oidc_issuer_url, oauth_authorization_path).to_s
38+
end
39+
40+
def oidc_end_session_endpoint
41+
URI.join(oidc_issuer_url, destroy_user_session_path).to_s
42+
end
2543
end

app/controllers/oauth_sessions_controller.rb

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,20 @@ def exchange
4646

4747
def refresh
4848
refresh_token_value = cookies[COOKIE_NAME]
49-
return render_oauth_error(:invalid_grant, status: :unauthorized) if refresh_token_value.blank?
49+
if refresh_token_value.blank?
50+
report_refresh_failure(:cookie_absent)
51+
return render_oauth_error(:invalid_grant, status: :unauthorized)
52+
end
5053

5154
access_token = Doorkeeper::AccessToken.by_refresh_token(refresh_token_value)
52-
return render_oauth_error(:invalid_grant, status: :unauthorized) if access_token.nil?
55+
if access_token.nil?
56+
# The cookie carried a refresh token but no access token row matches it.
57+
# The most likely cause is that the row was deleted out from under the
58+
# cookie (e.g. the nightly CleanupDbService pruning a revoked token) — this
59+
# is the case we most want to catch for the "logged out overnight" reports.
60+
report_refresh_failure(:token_not_found)
61+
return render_oauth_error(:invalid_grant, status: :unauthorized)
62+
end
5363

5464
frontend_app = OAuthApplication.find_by(is_intercode_frontend: true)
5565
return render_oauth_error(:invalid_client, status: :bad_request) unless frontend_app
@@ -59,7 +69,7 @@ def refresh
5969
credentials = Doorkeeper::OAuth::Client::Credentials.new(frontend_app.uid, nil)
6070
request = Doorkeeper::OAuth::RefreshTokenRequest.new(Doorkeeper.config, access_token, credentials)
6171

62-
respond_with_token_request(request)
72+
respond_with_token_request(request, refreshed_token: access_token)
6373
end
6474

6575
def sign_out
@@ -74,9 +84,39 @@ def sign_out
7484

7585
private
7686

77-
def respond_with_token_request(request)
87+
# Records *why* a cookie-backed refresh failed, so we can diagnose the
88+
# convention-site "logged out overnight" reports from real data instead of
89+
# guesswork. Deliberately logs no token material — just the failure reason
90+
# and (when a row exists) its lifecycle timestamps, which let us distinguish
91+
# a deleted row from one that was revoked by rotation. Surfaces to Sentry and
92+
# Rollbar with a filterable `oauth_refresh_failure` tag.
93+
def report_refresh_failure(reason, access_token: nil, doorkeeper_error: nil)
94+
context = { reason: reason, doorkeeper_error: doorkeeper_error }.compact
95+
96+
if access_token
97+
context.merge!(
98+
resource_owner_id: access_token.resource_owner_id,
99+
application_id: access_token.application_id,
100+
token_created_at: access_token.created_at,
101+
token_revoked_at: access_token.revoked_at,
102+
token_expires_in: access_token.expires_in,
103+
has_previous_refresh_token: access_token.previous_refresh_token.present?
104+
)
105+
end
106+
107+
ErrorReporting.info("oauth_session refresh failed", tags: { oauth_refresh_failure: reason.to_s }, **context)
108+
end
109+
110+
def respond_with_token_request(request, refreshed_token: nil)
78111
response = request.authorize
79112
if response.is_a?(Doorkeeper::OAuth::ErrorResponse)
113+
# `refreshed_token` is only passed by the refresh flow: the access token row
114+
# exists but Doorkeeper rejected the grant (e.g. it was already revoked, or
115+
# this is refresh-token reuse). Capturing the row's state lets us tell a
116+
# rotation/race problem apart from the row being deleted entirely.
117+
if refreshed_token
118+
report_refresh_failure(:grant_rejected, access_token: refreshed_token, doorkeeper_error: response.name)
119+
end
80120
clear_refresh_cookie
81121
render json: response.body, status: response.status
82122
return

app/controllers/sessions_controller.rb

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,22 @@ def create
1313
set_flash_message!(:notice, :signed_in)
1414
sign_in(resource_name, resource)
1515
path = after_sign_in_path_for(resource)
16-
uri = parse_uri_silently(path.to_s)
17-
redirect_to path, allow_other_host: uri&.host.present? && trusted_origin?(uri.host)
16+
17+
respond_to do |format|
18+
# The SPA sign-in form posts with `Accept: application/json` and performs the
19+
# post-sign-in redirect itself, as a top-level navigation. We must NOT issue an
20+
# HTTP redirect for it: fetch() follows the redirect chain as subresource
21+
# requests, so when that chain crosses into a third-party OAuth app's callback
22+
# (i.e. signing in to a relying app) the cross-origin hop is CORS-blocked — and
23+
# the one-time authorization code is burned in the process. Handing the location
24+
# back as JSON lets the browser walk the redirect chain natively via a top-level
25+
# navigation, which isn't subject to CORS.
26+
format.json { render json: { location: safe_sign_in_location(path) } }
27+
format.any do
28+
uri = parse_uri_silently(path.to_s)
29+
redirect_to path, allow_other_host: uri&.host.present? && trusted_origin?(uri.host)
30+
end
31+
end
1832
end
1933

2034
# Revoke the user's intercode-frontend access tokens before Devise tears down
@@ -41,6 +55,16 @@ def respond_to_on_destroy(non_navigational_status: :no_content)
4155

4256
private
4357

58+
# Only hand the client a location it's safe to send the browser to: same-origin
59+
# (no host) or a trusted origin. Mirrors the `allow_other_host` guard on the
60+
# navigational redirect so the JSON path can't be coerced into an open redirect.
61+
def safe_sign_in_location(path)
62+
uri = parse_uri_silently(path.to_s)
63+
return path.to_s if uri&.host.blank? || trusted_origin?(uri.host)
64+
65+
root_path
66+
end
67+
4468
def after_sign_out_path_for(_resource_or_scope)
4569
trusted_referer_url || root_path
4670
end

app/javascript/AppContexts.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import AuthenticityTokensManager from 'AuthenticityTokensContext';
1010
export type ClientConfiguration = {
1111
oauth_frontend_application_uid: string | null;
1212
oidc_issuer_url: string | null;
13+
oidc_authorization_endpoint: string | null;
14+
oidc_end_session_endpoint: string | null;
1315
rails_default_active_storage_service_name: string;
1416
rails_direct_uploads_url: string;
1517
recaptcha_site_key: string | null;

app/javascript/AppRoot.tsx

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Suspense, useMemo, useState, useEffect, useRef } from 'react';
2-
import { useLocation, useLoaderData, useRouteLoaderData, Outlet, useNavigation } from 'react-router';
2+
import { useLocation, useLoaderData, Outlet, useNavigation } from 'react-router';
33
import { Settings } from 'luxon';
44
import { PageLoadingIndicator } from '@neinteractiveliterature/litform';
55

@@ -11,8 +11,7 @@ import { timespanFromConvention } from './TimespanUtils';
1111
import { LazyStripeContext } from './LazyStripe';
1212
import { Stripe } from '@stripe/stripe-js';
1313
import { reloadOnAppEntrypointHeadersMismatch } from './checkAppEntrypointHeadersMatch';
14-
import { initErrorReporting } from 'ErrorReporting';
15-
import { RootLoaderData } from 'root';
14+
import errorReporting from 'ErrorReporting';
1615

1716
export function buildAppRootContextValue(
1817
data: AppRootQueryData | null | undefined,
@@ -82,15 +81,12 @@ function AppRoot(): React.JSX.Element {
8281
const navigationBarRef = useRef<HTMLElement>(null);
8382

8483
const [stripePromise, setStripePromise] = useState<Promise<Stripe | null> | null>(null);
85-
const rootLoaderData = useRouteLoaderData('root') as RootLoaderData | undefined;
8684

8785
useEffect(() => {
88-
initErrorReporting(
89-
data.currentUser?.id,
90-
rootLoaderData?.clientConfiguration?.sentry_frontend_dsn,
91-
rootLoaderData?.clientConfiguration?.rollbar_client_access_token,
92-
);
93-
}, [data?.currentUser?.id, rootLoaderData?.clientConfiguration]);
86+
// Error reporting is initialized in the boot sequence (see packs/application.tsx);
87+
// here we just associate subsequent reports with the signed-in user.
88+
errorReporting().setCurrentUser(data.currentUser?.id);
89+
}, [data?.currentUser?.id]);
9490

9591
useEffect(() => {
9692
reloadOnAppEntrypointHeadersMismatch();

app/javascript/AppRouter.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ export function AppRootContextRouteGuard({ guard }: AppRootContextRouteGuardProp
117117
function LoginRequiredRouteGuard() {
118118
const loginRequired = useLoginRequired();
119119
if (loginRequired) {
120-
return <></>;
120+
return loginRequired;
121121
}
122122

123123
return <Outlet />;

app/javascript/Authentication/DeviseSignInPage.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,16 @@ function DeviseSignInPage() {
2626
try {
2727
const response = await fetch('/users/sign_in', {
2828
method: 'POST',
29+
headers: { Accept: 'application/json' },
2930
body: new FormData(event.currentTarget),
3031
});
3132
if (response.ok) {
32-
window.location.href = response.url || '/';
33+
// The server returns the post-sign-in location as JSON rather than
34+
// redirecting, so the browser navigates the redirect chain top-level.
35+
// If we let fetch() follow it, a cross-origin hop into a relying OAuth
36+
// app's callback would be CORS-blocked (and burn the one-time code).
37+
const data = (await response.json()) as { location?: string };
38+
window.location.href = data.location ?? '/';
3339
} else {
3440
const data = (await response.json()) as { error?: string };
3541
setError(data.error ?? t('authentication.signInForm.genericError'));

app/javascript/Authentication/authenticationManager.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Configuration } from 'openid-client';
2-
import { discoverOpenidConfig, generatePKCEChallenge, getAuthorizationRedirectURL } from './openid';
2+
import { buildOpenidConfig, generatePKCEChallenge, getAuthorizationRedirectURL } from './openid';
33
import { createContext } from 'react';
44
import * as z from 'zod/mini';
55

@@ -53,6 +53,8 @@ type TokenResponseBody = {
5353
export class AuthenticationManager {
5454
clientId?: string;
5555
issuerUrl?: string;
56+
authorizationEndpoint?: string;
57+
endSessionEndpoint?: string;
5658
openidConfig?: Configuration;
5759
currentLoginFlowData?: LoginFlowData;
5860
jwtToken?: string;
@@ -99,7 +101,15 @@ export class AuthenticationManager {
99101
throw new Error('OAuth client ID not configured');
100102
}
101103

102-
this.openidConfig = await discoverOpenidConfig(this.clientId, this.issuerUrl);
104+
if (!this.issuerUrl) {
105+
throw new Error('OIDC issuer URL not configured');
106+
}
107+
108+
this.openidConfig = buildOpenidConfig(this.clientId, {
109+
issuer: this.issuerUrl,
110+
authorizationEndpoint: this.authorizationEndpoint,
111+
endSessionEndpoint: this.endSessionEndpoint,
112+
});
103113
return this.openidConfig;
104114
}
105115

app/javascript/Authentication/openid.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import {
2-
discovery,
32
buildAuthorizationUrl,
43
Configuration,
54
randomPKCECodeVerifier,
@@ -12,13 +11,28 @@ export type PKCEChallengeData = {
1211
state: string;
1312
};
1413

15-
export async function discoverOpenidConfig(clientId: string, issuerUrl?: string): Promise<Configuration> {
16-
const issuer = new URL(issuerUrl ?? window.location.href);
17-
issuer.pathname = '/';
18-
issuer.search = '';
19-
issuer.hash = '';
20-
const config = await discovery(issuer, clientId);
21-
return config;
14+
export type OpenidServerMetadata = {
15+
issuer: string;
16+
authorizationEndpoint?: string;
17+
endSessionEndpoint?: string;
18+
};
19+
20+
// Build the openid-client Configuration from metadata we already have (delivered
21+
// same-origin via /client_configuration) rather than fetching the OIDC discovery
22+
// document. Discovery is a cross-origin request to the issuer host, which gets
23+
// blocked (Brave shields, untrusted dev certs) and silently wedges login. We only
24+
// need the authorization endpoint (to build the redirect) and the end-session
25+
// endpoint (for sign-out); token exchange/refresh go through our own same-origin
26+
// /oauth_session/* endpoints.
27+
export function buildOpenidConfig(clientId: string, metadata: OpenidServerMetadata): Configuration {
28+
return new Configuration(
29+
{
30+
issuer: metadata.issuer,
31+
authorization_endpoint: metadata.authorizationEndpoint,
32+
end_session_endpoint: metadata.endSessionEndpoint,
33+
},
34+
clientId,
35+
);
2236
}
2337

2438
export async function generatePKCEChallenge(): Promise<PKCEChallengeData> {

0 commit comments

Comments
 (0)