Skip to content

Commit c85f06e

Browse files
nbudinclaude
andcommitted
Build the OIDC config from same-origin metadata instead of cross-origin discovery
Initiating login awaited an OIDC discovery fetch to the issuer host (/.well-known/openid-configuration). From a convention page that's a cross-origin request to the root site — which gets blocked (Brave shields in production; untrusted self-signed certs in local dev, where it showed up as `GET 0 /.well-known/openid-configuration`). When it fails, initiateAuthentication can't build the redirect URL and login wedges — the root of the white screen. The SPA only needs three things from the issuer: the issuer URL, the authorization endpoint (to build the redirect) and the end-session endpoint (for sign-out); token exchange/refresh already go through our own same-origin /oauth_session/* endpoints. So serve those in /client_configuration (already fetched same-origin at boot) and construct openid-client's Configuration directly, dropping the discovery() call entirely. No more cross-origin dependency in the login path. The endpoints are built by joining the issuer URL with the route paths, so a convention page gets the root-site endpoints regardless of which host served the request. Tests: openid.test.ts confirms the authorization URL and end-session endpoint come out of the metadata-built Configuration (no fetch); a client_configuration controller test confirms the endpoints are served on the issuer host. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e9c614a commit c85f06e

7 files changed

Lines changed: 108 additions & 10 deletions

File tree

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/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/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> {

app/javascript/packs/application.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ const bootstrapPromise: Promise<Bootstrap> = (async () => {
6262
clientConfiguration.oauth_frontend_application_uid ?? undefined,
6363
);
6464
authManager.issuerUrl = clientConfiguration.oidc_issuer_url ?? undefined;
65+
authManager.authorizationEndpoint = clientConfiguration.oidc_authorization_endpoint ?? undefined;
66+
authManager.endSessionEndpoint = clientConfiguration.oidc_end_session_endpoint ?? undefined;
6567

6668
// Try to redeem the refresh cookie for an access token before we build the
6769
// Apollo client, so the first GraphQL query goes out authenticated when the
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# frozen_string_literal: true
2+
require "test_helper"
3+
4+
class ClientConfigurationControllerTest < ActionDispatch::IntegrationTest
5+
it "serves the OIDC endpoints same-origin so the SPA needn't fetch cross-origin discovery" do
6+
get "/client_configuration"
7+
8+
assert_response :ok
9+
config = response.parsed_body
10+
issuer = config["oidc_issuer_url"]
11+
12+
assert issuer.present?, "expected an issuer URL"
13+
# Endpoints are absolute URLs on the *issuer* host (not whatever host requested this),
14+
# built from the issuer so a convention page gets the root-site endpoints.
15+
assert config["oidc_authorization_endpoint"].start_with?(issuer)
16+
assert config["oidc_authorization_endpoint"].end_with?("/oauth/authorize")
17+
assert config["oidc_end_session_endpoint"].start_with?(issuer)
18+
assert config["oidc_end_session_endpoint"].end_with?("/users/sign_out")
19+
end
20+
end
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { buildOpenidConfig, getAuthorizationRedirectURL } from '../../../app/javascript/Authentication/openid';
2+
3+
describe('buildOpenidConfig', () => {
4+
const metadata = {
5+
issuer: 'https://issuer.example.com/',
6+
authorizationEndpoint: 'https://issuer.example.com/oauth/authorize',
7+
endSessionEndpoint: 'https://issuer.example.com/users/sign_out',
8+
};
9+
10+
it('builds an authorization redirect URL from metadata, with no discovery fetch', () => {
11+
const config = buildOpenidConfig('test-client-id', metadata);
12+
13+
const url = getAuthorizationRedirectURL(
14+
config,
15+
{ verifier: 'test-verifier', challenge: 'test-challenge', state: 'test-state' },
16+
'test-client-id',
17+
);
18+
19+
expect(`${url.origin}${url.pathname}`).toBe('https://issuer.example.com/oauth/authorize');
20+
expect(url.searchParams.get('client_id')).toBe('test-client-id');
21+
expect(url.searchParams.get('response_type')).toBe('code');
22+
expect(url.searchParams.get('code_challenge')).toBe('test-challenge');
23+
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
24+
expect(url.searchParams.get('nonce')).toBe('test-state');
25+
});
26+
27+
it('exposes the end session endpoint for sign-out', () => {
28+
const config = buildOpenidConfig('test-client-id', metadata);
29+
30+
expect(config.serverMetadata().end_session_endpoint).toBe('https://issuer.example.com/users/sign_out');
31+
});
32+
});

0 commit comments

Comments
 (0)