Skip to content

feat: OIDC auth#283

Draft
alukach wants to merge 32 commits into
mainfrom
feat/oidc-auth
Draft

feat: OIDC auth#283
alukach wants to merge 32 commits into
mainfrom
feat/oidc-auth

Conversation

@alukach
Copy link
Copy Markdown
Contributor

@alukach alukach commented Apr 7, 2026

What I'm changing

How I did it

How you can test it

alukach and others added 8 commits April 5, 2026 21:50
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add authenticateWithOidcToken function that validates JWT tokens signed
by the data proxy's OIDC provider, checking signature, issuer, audience,
and expiry before resolving the subject claim to a UserSession.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
backend roles will be making requests as either organizations or users
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 7, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
source-cooperative Ready Ready Preview, Comment Apr 20, 2026 10:07pm

Request Review

… for file path

When the server-side HEAD check fails and falls through to the client-side
directory listing with a file path (e.g. catalog.json), the listing returns
empty. Recursively render with the parent prefix so the user sees the file
in context instead of an infinite loading state.
@claude
Copy link
Copy Markdown

claude Bot commented Apr 20, 2026

Claude encountered an error —— View job


I'll analyze this and get back to you.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 20, 2026

Claude encountered an error —— View job


PR Review: feat/OIDC auth

  • Gather context and read CLAUDE.md
  • Review OIDC implementation (src/lib/api/oidc.ts)
  • Review proxy credentials (src/lib/actions/proxy-credentials.ts)
  • Review S3 read service (src/lib/services/s3-read.ts)
  • Review auth changes (src/lib/api/authz.ts, src/lib/api/utils.ts)
  • Review UI components (ProxyCredentialsProvider, ProductFileBrowser)
  • Review tests and coverage
  • Review security considerations
  • Post final review

@claude
Copy link
Copy Markdown

claude Bot commented Apr 20, 2026

Claude finished @alukach's task in 2m 56s —— View job


PR Review: feat/OIDC auth

  • Gather context
  • Read codebase conventions
  • Review OIDC implementation (src/lib/api/oidc.ts)
  • Review proxy credentials (src/lib/actions/proxy-credentials.ts)
  • Review S3 read service (src/lib/services/s3-read.ts)
  • Review auth changes (src/lib/api/authz.ts, src/lib/api/utils.ts)
  • Review UI components
  • Review tests
  • Security analysis
  • Post final review

Overall this is a solid implementation of OIDC-based data proxy authentication. The test coverage is good and the architecture is sound. Below are issues found, roughly ordered by severity.


Security

1. getApiSession falls through to cookie auth when Bearer token is invalid
src/lib/api/utils.ts:56-79

When an Authorization header is present but OIDC validation fails, the function silently falls through to cookie-based auth. A request with a deliberately bad JWT (perhaps probing for vulnerabilities) will succeed via its session cookie. The intent might be that browsers use cookies while the proxy uses JWTs, but an explicit failed auth attempt should probably return null rather than retrying a different method.

// Current behaviour — falls through silently
if (authorization) {
  const oidcSession = await authenticateWithOidcToken(authorization, audience);
  if (oidcSession) return oidcSession;
  // ← returns null here would be more defensive
}
return getPageSession();

Consider returning null when an Authorization header is present but invalid. Fix this →


2. Error responses include raw Hydra response bodies
src/lib/actions/proxy-credentials.ts:110-113, 168-171

throw new Error(`Login accept failed: ${loginAcceptResp.status} ${await loginAcceptResp.text()}`);
throw new Error(`Token exchange failed: ${tokenResp.status} ${await tokenResp.text()}`);

Hydra error bodies could contain sensitive request parameters or internal details. These get propagated through throw and could surface in logs or error responses to clients. Use LOGGER.error with sanitised details and throw a generic message.


3. Redirect target not validated before following
src/lib/actions/proxy-credentials.ts:119-144

When following the post-login redirect, the URL is resolved relative to loginRedirect and followed without verifying it stays within the expected domain:

const resolvedUrl = new URL(location, loginRedirect).toString();
const followResp = await fetchWithCookies(resolvedUrl, cookieJar, { redirect: "manual" });

If Hydra is misconfigured or compromised, this could follow redirects to unexpected hosts while carrying the session cookie jar (and potentially the admin API key via Authorization headers). A check like new URL(resolvedUrl).origin === backendUrl would guard against this.


4. _setJwks is exported from a production module
src/lib/api/oidc.ts:16-18

/** @internal Exposed for testing — override to supply a local JWKS resolver. */
export function _setJwks(fn: ReturnType<typeof createRemoteJWKSet> | null) {
  jwks = fn;
}

This is exported from a production module, allowing any importer to replace the JWT verification key resolver. TypeScript's @internal is a documentation convention only — it does not restrict imports. For test overriding, consider using jest.mock on the jose module directly rather than exporting a setter. If the setter must stay, at minimum add a guard: if (process.env.NODE_ENV !== 'test') throw.


5. Regex-based STS XML parsing
src/lib/actions/proxy-credentials.ts:227-246

The XML is parsed with regex. If credential values contain XML-escaped characters (e.g. &amp;, &lt;), they will be returned verbatim rather than decoded. This would cause signature failures when using the credentials. Since STS typically base64-encodes tokens, this is low probability but worth noting.


Performance / Architecture

6. ProxyCredentialsProvider is hoisted to the app-wide layout
src/app/(app)/layout.tsx:14-15

export default async function AppLayout({ children }: AppLayoutProps) {
  const session = await getPageSession();
  const isAuthenticated = !!session?.identity_id;
  return (
    <ProxyCredentialsProvider isAuthenticated={isAuthenticated}>

For every authenticated page load — account pages, settings, search results, etc. — this triggers the full Hydra OAuth2 authorization code flow (4–6 HTTP calls) on component mount. This is a meaningful latency hit for non-product pages. Consider moving the provider to the product-specific layout (src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx) where it's actually needed.


7. ProxyCredentials lacks caching between page navigations
src/components/features/products/ProxyCredentialsProvider.tsx:46-53

The credentials are stored in React state. Every time the provider re-mounts (e.g. navigating away and back), the full Hydra flow runs again even if the previous credentials are still valid. The proactive refresh on expiry is a good touch, but consider persisting credentials in sessionStorage/localStorage (with careful expiry checking) to avoid redundant fetches.


Code Quality

8. Unused getObject in S3ReadClient
src/lib/services/s3-read.ts:88-92

getObject is defined but not called from anywhere in the PR. If it's not needed yet, remove it to avoid dead code. Fix this →


9. Duplication between S3ReadClient and S3StorageClient
src/lib/services/s3-read.ts vs src/lib/storage/s3.ts

Both clients implement the no-op signer pattern and S3 list/get logic. The split is justified (server vs. browser environment), but the unsigned-request approach in s3.ts:38-42 and s3-read.ts:58 are identical. A shared noopSigner constant would prevent drift.


10. ProductFileBrowser self-recursion is unbounded in theory
src/components/features/products/object-browser/ProductFileBrowser.tsx:90-99

if (isEmpty && prefix) {
  return <ProductFileBrowser ... prefix={parent} ... />
}

Each recursion strips one path segment, so it will terminate at an empty prefix. But each step is a new S3 request, and a deeply nested path would make N network calls before showing content. A flattened loop or resolving parent server-side would be more predictable.


11. CONFIG.storage.endpoint used as both JWKS issuer and STS base URL
src/lib/api/oidc.ts:21 and src/lib/actions/proxy-credentials.ts:31

The same config key drives OIDC token verification (issuer claim) and STS endpoint construction. This is fine if the data proxy hosts both, but it couples the two concerns to a single env var. If the STS and JWKS endpoints ever diverge, this will require a config change. A comment explaining the coupling would help future maintainers.


12. Missing /api/internal/oauth2/callback route

The redirectUri in config points to /api/internal/oauth2/callback (config.ts:56), but this route doesn't appear in the changed files. The Hydra auth code flow requires this endpoint to exist as a registered redirect URI. Verify it's already in the codebase and that Hydra's client registration matches.


Minor

  • src/lib/api/utils.ts:87-122: getPageSession now runs for every request that doesn't have a Bearer token. The logging says "falling back to cookie-based authentication" even for requests that never had a token. The log message condition could be tightened to only emit when authorization was present but failed.
  • src/lib/services/s3-read.ts:58: sign: async (request: any) => request — the any could be typed as HttpRequest from @smithy/types to match what s3.ts needs.

Comment thread src/lib/api/utils.ts
authorization,
audience,
);
if (oidcSession) return oidcSession;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a Bearer token is explicitly provided but OIDC validation fails, this code falls through and authenticates via session cookie instead. A request with an invalid JWT should not silently succeed via an alternative auth path — consider returning null here when authorization was present:

Suggested change
if (oidcSession) return oidcSession;
if (oidcSession) return oidcSession;
// An explicit but invalid bearer token should not fall back to cookie auth.
return null;

Comment thread src/lib/api/oidc.ts
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;

/** @internal Exposed for testing — override to supply a local JWKS resolver. */
export function _setJwks(fn: ReturnType<typeof createRemoteJWKSet> | null) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This setter is exported from a production module — any importer can override the JWKS resolver and bypass JWT signature validation. @internal is a docs-only convention in TypeScript; it doesn't restrict imports.

Options:

  1. Guard with if (process.env.NODE_ENV !== 'test') throw new Error('not allowed in production').
  2. Instead of exporting a setter, use jest.mock('jose', ...) in tests so the production code path is never altered.

},
);
if (!loginAcceptResp.ok) {
throw new Error(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Raw Hydra response bodies are included in thrown errors. These can contain sensitive OAuth2 parameters and internal error details that may surface in client responses or log aggregators. Prefer logging the body at LOGGER.error and throwing a sanitised message:

Suggested change
throw new Error(
const errBody = await loginAcceptResp.text();
LOGGER.error("Login accept failed", { operation: "getOryIdToken", metadata: { status: loginAcceptResp.status, body: errBody } });
throw new Error(`Login accept failed: ${loginAcceptResp.status}`);

if (!consentChallenge) {
const location = postLoginResp.headers.get("location");
if (location) {
const resolvedUrl = new URL(location, loginRedirect).toString();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The resolved redirect URL is followed without validating it remains within the expected backendUrl domain. If Hydra returns an unexpected redirect (misconfiguration or compromise), the cookie jar (and potentially auth state) would be sent to an unintended host.

Suggested change
const resolvedUrl = new URL(location, loginRedirect).toString();
if (location) {
const resolvedUrl = new URL(location, loginRedirect).toString();
if (!resolvedUrl.startsWith(backendUrl) && !resolvedUrl.startsWith(redirectUri)) {
throw new Error(`Unexpected redirect to untrusted URL: ${resolvedUrl}`);
}
const followResp = await fetchWithCookies(resolvedUrl, cookieJar, {

Comment thread src/app/(app)/layout.tsx
</Box>
<Footer />
</Flex>
<ProxyCredentialsProvider isAuthenticated={isAuthenticated}>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ProxyCredentialsProvider is mounted at the top-level app layout, so authenticated users trigger the full Hydra OAuth2 flow (4–6 HTTP requests) on every page — account settings, search, etc. — not just product pages.

Consider moving this provider to the product-specific layout (src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx) so the credential fetch only runs when the file browser is actually needed.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 20, 2026

Claude finished @alukach's task in 2m 3s —— View job


PR Review: feat/OIDC auth — updated review

  • Check what changed since last review
  • Read codebase conventions
  • Review updated code
  • Post updated feedback

This review covers the full current state of the branch, including commits added since the previous review. Several prior concerns remain open; a few new issues were introduced.


Still open from previous review

1. getApiSession falls through to cookie auth on invalid Bearer token
src/lib/api/utils.ts:56-74

The fallthrough is unchanged. When an Authorization header is present but OIDC validation fails, execution continues to return getPageSession() outside the if(authorization) block. The previous review's return null suggestion still stands. Fix this →

2. Raw Hydra response bodies in thrown errors
src/lib/actions/proxy-credentials.ts:110, 168, and acceptConsentAndGetCode

login accept failed, token exchange failed, and consent accept failed all still embed await resp.text() in the thrown message. These will appear in logs and potentially in client error responses. See previous review suggestion.

3. Redirect target not validated before following
src/lib/actions/proxy-credentials.ts:130

The resolvedUrl check against backendUrl / redirectUri was not added. Same concern as before.

4. _setJwks exported without production guard
src/lib/api/oidc.ts:16

Still no NODE_ENV !== 'test' guard. Fix this →

5. getObject still unused in S3ReadClient
src/lib/services/s3-read.ts:88


New issues

6. isDevelopment semantics changed — may be true on Vercel previews
src/lib/config.ts:79

// Before
isDevelopment: process.env.NODE_ENV === "development",

// After
isDevelopment: (process.env.STAGE || "dev") !== "prod",

On Vercel preview deployments where STAGE is not set, isDevelopment is now true even though NODE_ENV=production. Any code gating on isDevelopment (e.g., debug logs, relaxed validation) will behave differently in preview vs. prod. If the intent is to distinguish staging from production, use a separate flag rather than conflating it with the Node environment. Fix this →

7. Missing /api/internal/oauth2/callback route
src/lib/config.ts:53

redirectUri: `${process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"}/api/internal/oauth2/callback`,

src/app/api/internal/ doesn't exist. While the server-side OAuth flow may not need the callback endpoint to actually handle anything (the server drives the redirect itself), Hydra requires the redirect_uri to be a registered URI in the client configuration. If the endpoint doesn't exist and returns 404, the fetchWithCookies call following the redirect may not parse the code parameter correctly. Verify this works end-to-end, or add a minimal Next.js route handler at that path. Fix this →

8. GET /api/v1/products/[account_id]/[repository_id] lost its try/catch
src/app/api/v1/products/[account_id]/[repository_id]/route.ts:51-73

The DELETE handler still has error handling but GET no longer does. An unexpected DB error will now bubble up as an unhandled rejection rather than a structured 500 response.


What looks good

  • Auth simplification: API key / API secret auth removed, replaced with clean OIDC token path — the code is much leaner.
  • authz.ts migration from ProductDataMode enum to product.visibility string is correct and removes dead TODO comments.
  • Proactive credential refresh before expiry in ProxyCredentialsProvider is a nice touch.
  • config.ts anonymous access now via no-op signer rather than fake credentials.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant