Skip to content

feat: add OAuth 2.1 / OIDC provider support#3103

Merged
omar-inkeep merged 4 commits intomainfrom
feat/oauth-provider-setup
Apr 10, 2026
Merged

feat: add OAuth 2.1 / OIDC provider support#3103
omar-inkeep merged 4 commits intomainfrom
feat/oauth-provider-setup

Conversation

@omar-inkeep
Copy link
Copy Markdown
Contributor

Summary

  • Add @better-auth/oauth-provider plugin with jwt() support, enabling the platform to act as an OAuth 2.1 authorization server
  • Register OIDC discovery endpoints (/.well-known/openid-configuration, /.well-known/oauth-authorization-server/*)
  • Add OAuth schema tables (oauth_client, oauth_access_token, oauth_refresh_token, oauth_consent, jwks) with Drizzle relations and migration
  • Extend login page to detect OAuth authorize redirects (client_id in query params) and resume the flow after authentication
  • Add customAccessTokenClaims with tenantId and email claims for downstream OAuth clients

Known limitation

customAccessTokenClaims uses getInitialOrganization (first org by membership date). Multi-org users will get their earliest org's tenantId in the access token. A proper org selection flow (post-login org picker + token re-issuance) should be added when there's a real multi-org customer need.

Test plan

  • Verify /.well-known/openid-configuration returns valid OIDC metadata
  • Complete an OAuth authorization code flow end-to-end (authorize → login → callback → token exchange)
  • Verify access token contains correct tenantId and email claims
  • Verify login page correctly resumes OAuth flow after SSO/Google/email-password auth
  • Verify existing non-OAuth login flows are unaffected
  • Verify migration applies cleanly (pnpm db:migrate)

🤖 Generated with Claude Code

Enable the platform to act as an OAuth 2.1 authorization server so
downstream clients (CLI, SDK, MCP) can authenticate via standard
OAuth flows.

Changes:
- Add @better-auth/oauth-provider plugin with jwt() support
- Register OIDC discovery endpoints (/.well-known/openid-configuration,
  /.well-known/oauth-authorization-server/*)
- Add OAuth schema tables (oauth_client, oauth_access_token,
  oauth_refresh_token, oauth_consent, jwks) with Drizzle relations
- Extend login page to detect OAuth authorize redirects and resume
  the flow after authentication
- Add customAccessTokenClaims with tenantId and email claims

Known limitation: customAccessTokenClaims uses getInitialOrganization
(first org by membership date). Multi-org users will need an org
selection flow in a follow-up (post-login org picker + token
re-issuance).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 10, 2026

🦋 Changeset detected

Latest commit: bb36226

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 10 packages
Name Type
@inkeep/agents-core Major
@inkeep/agents-api Major
@inkeep/agents-manage-ui Major
@inkeep/agents-cli Major
@inkeep/agents-sdk Major
@inkeep/agents-work-apps Major
@inkeep/ai-sdk-provider Major
@inkeep/create-agents Major
@inkeep/agents-email Major
@inkeep/agents-mcp Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 10, 2026

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

Project Deployment Actions Updated (UTC)
agents-api Ready Ready Preview, Comment Apr 10, 2026 10:09pm
agents-manage-ui Ready Ready Preview, Comment Apr 10, 2026 10:09pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
agents-docs Skipped Skipped Apr 10, 2026 10:09pm

Request Review

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@github-actions github-actions Bot deleted a comment from claude Bot Apr 10, 2026
@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Apr 10, 2026

TL;DR — Turns Inkeep Agents into a standards-compliant OAuth 2.1 / OpenID Connect provider so external applications can authenticate users via Inkeep using authorization code + PKCE flows. Adds 5 new database tables for OAuth state, wires the @better-auth/oauth-provider and jwt plugins into the auth stack, exposes OIDC discovery endpoints, and makes the login UI OAuth-flow-aware.

Key changes

  • Add @better-auth/oauth-provider dependency — new catalog entry at v1.5.5, consumed by both agents-api and agents-core
  • Define 5 OAuth provider tables in auth-schema.tsjwks, oauthClient, oauthAccessToken, oauthRefreshToken, and oauthConsent with full Drizzle relations
  • Wire oauthProvider and jwt plugins into createAuth() — configures scopes (openid, profile, email, offline_access), custom access token claims with tenant ID and email, and consent/login page redirects
  • Mount OIDC / OAuth discovery endpoints in createApp.ts/.well-known/openid-configuration and /.well-known/oauth-authorization-server/*
  • Make login page OAuth-flow-aware — detects client_id in search params and preserves authorization context through all authentication paths (password, Google, SSO)
  • Add runtime migration 0036_swift_hammerhead — creates the 5 tables with 10 foreign key constraints

Summary | 12 files | 2 commits | base: mainfeat/oauth-provider-setup


OAuth provider schema and database tables

Before: No OAuth provider tables existed; the platform could only consume external auth, not issue its own tokens.
After: 5 new tables (jwks, oauth_client, oauth_access_token, oauth_refresh_token, oauth_consent) store registered OAuth clients, issued tokens, user consent records, and signing keys.

The oauthClient table is the central registration entity — it stores client credentials, redirect URIs, grant types, response types, PKCE requirements, and a metadata JSONB column for extensibility. Tokens reference clients via client_id (cascade delete) and sessions via session_id (set null on delete). All 5 tables get full Drizzle relation definitions, and existing userRelations and sessionRelations are extended with the new many() arms.

What foreign key cascade strategy is used?

Deleting a user cascades to their clients, tokens, and consents. Deleting a client cascades to its tokens and consents. Deleting a session sets token session_id to null rather than cascading — tokens remain valid even if the originating session expires.

auth-schema.ts · 0036_swift_hammerhead.sql · runtime-schema.ts


Auth plugin configuration and custom claims

Before: createAuth() used session-based auth only, with no token issuance capability.
After: Two new plugins — jwt() and oauthProvider() — enable full OAuth 2.1 authorization code flows with PKCE, JWT-based token issuance, and custom access token claims.

The oauthProvider plugin is configured with:

Setting Value
loginPage ${INKEEP_AGENTS_MANAGE_UI_URL}/login
consentPage ${INKEEP_AGENTS_MANAGE_UI_URL}/consent
scopes openid, profile, email, offline_access

Custom access token claims inject https://inkeep.com/tenantId (the user's active organization ID) and https://inkeep.com/email into every issued token. The built-in /token path is disabled via disabledPaths so the plugin's own token endpoint takes precedence, and storeSessionInDatabase is enabled to support session validation during authorization flows.

auth.ts · agents-core/package.json


OIDC discovery endpoints

Before: No OAuth/OIDC metadata endpoints existed.
After: Two well-known discovery routes are mounted when auth is enabled.

Endpoint Purpose
GET /.well-known/openid-configuration Standard OIDC Discovery document (RFC 8414)
GET /.well-known/oauth-authorization-server/* OAuth 2.1 Authorization Server Metadata

Both delegate to the Better Auth instance via the oauthProviderOpenIdConfigMetadata and oauthProviderAuthServerMetadata helpers from @better-auth/oauth-provider.

createApp.ts · agents-api/package.json


OAuth-aware login flow

Before: The login page always redirected authenticated users to / or a returnUrl.
After: When client_id is present in search params (indicating an OAuth authorization redirect), the login page preserves those params through all authentication paths and redirects to the API's /api/auth/oauth2/authorize endpoint after successful login.

Three authentication paths are handled:

  1. Already authenticateduseEffect detects the session and immediately redirects to the authorize endpoint
  2. Password login — after success, uses window.location.href (full navigation) instead of router.replace() to ensure cookies reach the authorize endpoint
  3. SSO / GooglegetFullCallbackURL() constructs a callback URL that loops back to /login?{oauthParams}, preserving the OAuth context through the external IdP round-trip

login/page.tsx

Pullfrog  | View workflow run | Triggered by Pullfrog | Using Claude Opus𝕏

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Apr 10, 2026

TL;DR — Adds OAuth 2.1 / OIDC provider capabilities so the Inkeep Agents platform can act as an identity provider for external clients. Integrates the @better-auth/oauth-provider plugin, creates the full data model (5 new tables), registers standard discovery endpoints, adds a user-facing consent page with unauthenticated-user redirect, and makes the login page OAuth-flow-aware.

Key changes

  • Add @better-auth/oauth-provider plugin to auth stack — Configures the OAuth provider with standard OIDC scopes, custom access token claims (tenant ID, email), and PKCE support.
  • Register .well-known discovery endpoints — Exposes /.well-known/openid-configuration and /.well-known/oauth-authorization-server/* on the API for standard OIDC/OAuth metadata discovery.
  • Create 5 new runtime database tablesjwks, oauth_client, oauth_access_token, oauth_refresh_token, oauth_consent with full Drizzle ORM schema, relations, and migration.
  • Add OAuth consent page with auth guard — New /consent route in the manage UI lets users review requested scopes and approve or deny access; unauthenticated visitors are automatically redirected to /login with OAuth params preserved.
  • Make login page OAuth-flow-aware — Detects OAuth authorization redirects via client_id, preserves query params through login/SSO flows, and resumes the authorization code flow post-authentication.

Summary | 15 files | 4 commits | base: mainfeat/oauth-provider-setup


OAuth 2.1 / OIDC provider plugin integration

Before: The platform only consumed external identity providers (SSO, Google OAuth) for user login.
After: The platform itself acts as an OAuth 2.1 / OIDC provider — external clients can perform authorization code flows with PKCE against the Inkeep Agents API.

The oauthProvider() plugin is added to Better Auth with four standard scopes (openid, profile, email, offline_access) and a customAccessTokenClaims function that enriches issued JWTs with namespaced claims (https://inkeep.com/tenantId, https://inkeep.com/email). The jwt() plugin is also added to enable JWT token generation. Session storage is explicitly set to database-backed (storeSessionInDatabase: true), which the OAuth provider requires to associate sessions with tokens. The /token path is disabled via disabledPaths to prevent direct token issuance outside the OAuth flow.

Why are the claim URIs namespaced? Using URIs like https://inkeep.com/tenantId follows the convention for custom JWT claims — it avoids collisions with registered OIDC claims and lets downstream services unambiguously identify Inkeep-specific context from the token.

auth.ts · createApp.ts · agents-api/package.json


OAuth data model and migration

Before: No tables for OAuth clients, tokens, consent, or key material.
After: 5 new tables (jwks, oauth_client, oauth_access_token, oauth_refresh_token, oauth_consent) with full foreign key relationships and cascade/set-null delete behavior.

The Drizzle ORM schema in auth-schema.ts defines all tables with proper typing — notably oauthClient includes ~30 columns covering the full OAuth 2.1 dynamic client registration model (redirectUris, grantTypes, responseTypes, requirePKCE, metadata as jsonb, etc.). Relations are wired bidirectionally: user → tokens/clients/consents (many), session → tokens (many, set null on delete), and oauthClient → tokens/consents (many, cascade on delete).

Table Purpose Key FK behavior
jwks JWKS signing keys Standalone
oauth_client Registered OAuth clients userIduser
oauth_access_token Issued access tokens Cascade from client/user; set null from session
oauth_refresh_token Refresh tokens Cascade from client/user; set null from session
oauth_consent User consent records Cascade from client/user

auth-schema.ts · 0036_swift_hammerhead.sql · runtime-schema.ts


OAuth consent page with auth guard

Before: No consent UI existed — the platform had no mechanism for users to approve or deny third-party OAuth client access.
After: A new /consent route presents a branded consent screen showing the requesting application name, requested scopes with human-readable labels, and Allow/Deny buttons. Unauthenticated users are automatically redirected to login with OAuth params preserved.

The ConsentForm component reads OAuth params (client_name, scope) from the URL, renders scope descriptions via a SCOPE_LABELS lookup, and POSTs the user's decision to /api/auth/oauth2/consent with credentials: 'include'. On success, it follows the redirect URL returned by the server to complete the authorization code flow. A useEffect guard checks session state on mount — if the user is unauthenticated (e.g. expired session), they are bounced to /login?${searchParams} so the OAuth flow can resume after sign-in.

consent/page.tsx · consent/layout.tsx


OAuth-aware login page

Before: The login page redirected to / or a returnUrl after authentication.
After: When the login page detects OAuth query params (client_id), it preserves them through the full authentication flow (including SSO callbacks) and redirects to /api/auth/oauth2/authorize to resume the authorization code flow.

The redirect priority chain is now: OAuth authorize URL > invitation ID > return URL > home. For SSO/social login, the callback URL loops back through the login page with the original OAuth search params so the post-login useEffect can detect the session and issue the final redirect to the authorize endpoint. Hard navigation (window.location.href) is used instead of client-side routing for OAuth redirects since they target the API domain.

login/page.tsx

Pullfrog  | View workflow run | Triggered by Pullfrog | Using Claude Opus𝕏

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

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

Severity: MAJOR — missing consent page will break the OAuth flow at runtime; missing indexes will degrade performance under load.

This PR introduces a well-structured OAuth 2.1 / OIDC provider using @better-auth/oauth-provider. The schema, relations, and discovery endpoint wiring are all solid. The disabledPaths: ['/token'] to block the JWT plugin's session-to-JWT exchange while leaving /oauth2/token active is a good security call. The client_secret hashing via SHA-256 is handled correctly by the plugin.

Key issues to address before merge:

  1. Missing consent pageoauthProvider({ consentPage: '.../consent' }) references a page that does not exist in agents-manage-ui/src/app/consent/. OAuth clients requesting consent will hit a 404.
  2. No indexes on new OAuth tables — 10 foreign key columns across 4 tables have no indexes. CASCADE deletes and common query patterns (token lookup by client_id, consent by user_id + client_id) will require sequential scans.
  3. JWKS private keys stored as plaintext — The jwks.private_key column stores key material as unencrypted text. If the database is compromised, all issued tokens can be forged. This is a better-auth design choice but should be tracked for hardening (TDE, KMS, or a custom adapter).
  4. CORS on discovery endpoints/.well-known/openid-configuration falls under defaultCorsConfig with restricted origins. Per RFC 8414, these should be publicly accessible. External OAuth clients fetching discovery metadata from different origins will be blocked.
  5. Missing changeset — This PR adds a new runtime feature to agents-core and agents-api but has no changeset entry.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

jwt(),
oauthProvider({
loginPage: `${env.INKEEP_AGENTS_MANAGE_UI_URL || 'http://localhost:3000'}/login`,
consentPage: `${env.INKEEP_AGENTS_MANAGE_UI_URL || 'http://localhost:3000'}/consent`,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The consentPage points to ${env.INKEEP_AGENTS_MANAGE_UI_URL}/consent, but no page exists at agents-manage-ui/src/app/consent/. OAuth flows that require user consent will redirect to a 404. Either create the consent page or, if consent is always skipped (skipConsent: true on all registered clients), document that assumption explicitly.

if (!user) return {};
const org = await getInitialOrganization(config.dbClient, user.id);
return {
'https://inkeep.com/tenantId': org?.id ?? undefined,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

org?.id ?? undefined — when org is null (user has no org membership), this emits 'https://inkeep.com/tenantId': undefined. Depending on the JWT serializer, undefined is either omitted or serialized as null. If downstream consumers rely on this claim's presence to determine authorization, a missing claim could silently grant or deny access. Consider returning an empty object early or throwing when org is null, depending on whether org-less users should be able to complete the OAuth flow at all.

appName: 'Inkeep Agents',
baseURL: config.baseURL,
secret: config.secret,
disabledPaths: ['/token'],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good call disabling the JWT plugin's /token endpoint. This forces token issuance through the OAuth 2.1 flow exclusively (/oauth2/token), which enforces client authentication, PKCE, and scope validation. Worth a brief inline note (or commit message mention) so future maintainers don't remove this thinking it's a mistake.

export const jwks = pgTable('jwks', {
id: text('id').primaryKey(),
publicKey: text('public_key').notNull(),
privateKey: text('private_key').notNull(),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The private_key column stores JWK key material as plaintext. If the runtime database is compromised, an attacker can sign arbitrary access/ID tokens. This is better-auth's default behavior — not something to fix in this PR — but it should be tracked as a follow-up hardening item (database-level TDE, application-level envelope encryption, or external KMS/HSM for key storage).

Comment on lines +127 to +164
export const oauthRefreshToken = pgTable('oauth_refresh_token', {
id: text('id').primaryKey(),
token: text('token').notNull(),
clientId: text('client_id')
.notNull()
.references(() => oauthClient.clientId, { onDelete: 'cascade' }),
sessionId: text('session_id').references(() => session.id, {
onDelete: 'set null',
}),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
referenceId: text('reference_id'),
expiresAt: timestamp('expires_at'),
createdAt: timestamp('created_at'),
revoked: timestamp('revoked'),
authTime: timestamp('auth_time'),
scopes: text('scopes').array().notNull(),
});

export const oauthAccessToken = pgTable('oauth_access_token', {
id: text('id').primaryKey(),
token: text('token').unique(),
clientId: text('client_id')
.notNull()
.references(() => oauthClient.clientId, { onDelete: 'cascade' }),
sessionId: text('session_id').references(() => session.id, {
onDelete: 'set null',
}),
userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }),
referenceId: text('reference_id'),
refreshId: text('refresh_id').references(() => oauthRefreshToken.id, {
onDelete: 'cascade',
}),
expiresAt: timestamp('expires_at'),
createdAt: timestamp('created_at'),
scopes: text('scopes').array().notNull(),
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

None of the new OAuth tables have indexes on their foreign key columns (client_id, user_id, session_id, refresh_id). PostgreSQL does not auto-create indexes for FK columns (only for PK/unique constraints). This means:

  • ON DELETE CASCADE from user/session/oauth_client will sequential-scan the child tables
  • Token lookup by client_id, consent checks by (client_id, user_id), and refresh token revocation by user_id will all be slow

At minimum, add indexes on: oauth_access_token(client_id), oauth_access_token(user_id), oauth_refresh_token(client_id), oauth_refresh_token(user_id), oauth_consent(client_id, user_id), and oauth_client(user_id). Compare with existing auth tables which all have explicit indexes (e.g., session_userId_idx, account_userId_idx).

Comment on lines +136 to +141
app.get('/.well-known/openid-configuration', (c) => {
return openIdConfigHandler(c.req.raw);
});
app.get('/.well-known/oauth-authorization-server/*', (c) => {
return authServerMetadataHandler(c.req.raw);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These OIDC/OAuth discovery endpoints fall under defaultCorsConfig, which restricts origins to the manage UI domain and same-base-domain origins. Per RFC 8414 and OpenID Connect Discovery 1.0, these are public metadata endpoints expected to be accessible cross-origin. External OAuth clients (running on different domains) fetching /.well-known/openid-configuration from a browser will be blocked by CORS.

Consider adding a permissive CORS config for these routes specifically:

app.use('/.well-known/openid-configuration', cors({ origin: '*', maxAge: 86400 }));
app.use('/.well-known/oauth-authorization-server/*', cors({ origin: '*', maxAge: 86400 }));

// when redirecting unauthenticated users to the login page. After login,
// we must redirect back to the authorize endpoint with those same params
// so the OAuth flow can resume and issue the authorization code.
const isOAuthFlow = searchParams.has('client_id');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

searchParams.has('client_id') is a broad heuristic — any link to /login?client_id=foo will trigger OAuth redirect behavior, even if the client_id is invalid or malicious. The redirect ultimately goes to the API's /api/auth/oauth2/authorize which validates the client, so there's no open-redirect vulnerability here, but it does change the login UX (skipping router.replace in favor of window.location.href). Consider adding a comment noting that server-side validation of the client_id is the security boundary, not this client-side check.

// so the OAuth flow can resume and issue the authorization code.
const isOAuthFlow = searchParams.has('client_id');
const oauthAuthorizeUrl = isOAuthFlow
? `${PUBLIC_INKEEP_AGENTS_API_URL}/api/auth/oauth2/authorize?${searchParams.toString()}`
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

searchParams.toString() forwards ALL query parameters to the authorize endpoint. If someone adds extra parameters to the login URL (e.g., ?client_id=x&prompt=none), they all get passed through. This is likely fine since @better-auth/oauth-provider should ignore unknown params, but it's worth being aware of — especially for parameters like prompt or login_hint that have defined OAuth semantics.

},
},
session: {
storeSessionInDatabase: true,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This setting only has an effect when secondaryStorage (e.g., Redis) is configured — without it, sessions are always stored in the database. Since there's no secondaryStorage in the current config, this is a no-op today. It's good as a defensive measure for future-proofing, but the PR description implies it's a behavioral change.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

PR Review Summary

(8) Total Issues | Risk: High

This PR adds OAuth 2.1/OIDC provider support via Better Auth's oauth-provider plugin. While the implementation follows Better Auth patterns correctly, there are several critical and major issues that should be addressed before merging—particularly around the missing consent page, credential storage, and multi-org tenant claims.


🔴❗ Critical (1) ❗🔴

Inline Comments:

  • 🔴 Critical: packages/agents-core/src/auth/auth.ts:208 Missing /consent page will break OAuth flows requiring user consent

🟠⚠️ Major (4) ⚠️🟠

🟠 1) packages/agents-core/src/auth/auth-schema.ts:86-92 JWT private keys stored in plaintext

Issue: The jwks table stores JWT signing private keys in plaintext. These keys can sign access tokens for any OAuth client, making them high-value targets.

Why: If the database is compromised (SQL injection, backup leak, insider access), attackers can forge arbitrary access tokens and impersonate any user. This is a cryptographic key storage concern (CWE-321).

Fix: Verify if Better Auth supports custom key providers or encrypted storage. Consider application-level encryption with keys in a secret manager (AWS KMS, Vault). At minimum, ensure database encryption at rest is enabled and document this as an accepted operational requirement.

Refs:


🟠 2) packages/agents-core/src/auth/auth.ts:210-217 Multi-org tenantId claim uses non-deterministic organization selection

Issue: customAccessTokenClaims uses getInitialOrganization() which returns the user's first organization by membership createdAt date. For multi-org users, the access token embeds an arbitrary tenantId regardless of the intended authorization context.

Why: Downstream systems relying on the https://inkeep.com/tenantId claim will receive the wrong tenant context for multi-org users. This could lead to data access under incorrect tenant permissions or cross-tenant exposure if tokens are used in a different org context. The PR acknowledges this limitation but ships it as-is.

Fix: Consider scoping OAuth clients to specific organizations (derive tenantId from the client registration). Alternatively, add an org selection step during the OAuth consent flow. At minimum, create a tracked issue for the multi-org picker flow and add a prominent code comment.

Refs:


🟠 3) system No automated tests for security-critical OAuth functionality

Issue: This PR adds a significant security-critical authentication subsystem (OAuth 2.1 provider, JWT signing, custom claims, token storage) with no accompanying unit or integration tests.

Why: The customAccessTokenClaims function contains critical business logic determining what claims are embedded in access tokens. The OIDC discovery endpoints must return spec-compliant responses. The login page OAuth redirect logic handles the critical handoff between authentication and authorization. Without tests:

  • Regressions in claim generation could cause cross-tenant access issues
  • Malformed discovery responses would break all OAuth clients
  • Incorrect redirect handling could cause authentication loops

Fix: Add tests covering:

  1. customAccessTokenClaims callback (null user, user with org, user without org, multi-org user)
  2. OIDC discovery endpoints (/.well-known/openid-configuration returns required fields)
  3. Login page OAuth flow detection and redirect construction
  4. Schema cascade delete behavior for OAuth tables

Refs:


Inline Comments:

  • 🟠 Major: packages/agents-core/src/auth/auth.ts:210-217 Multi-org tenantId claim issue (detailed above)
  • 🟠 Major: packages/agents-core/src/auth/auth-schema.ts:86-92 JWT private keys in plaintext
  • 🟠 Major: packages/agents-core/src/auth/auth-schema.ts:129 Missing unique constraint on refresh token

🟡 Minor (2) 🟡

🟡 1) packages/agents-core/src/auth/auth-schema.ts:94-97 OAuth client secrets stored in plaintext

Issue: The oauth_client table stores clientSecret in plaintext. Client secrets authenticate OAuth clients during token exchange.

Why: Database compromise would expose all OAuth client secrets. While client secrets are less sensitive than user credentials, storing them hashed provides defense-in-depth. This may be a Better Auth framework limitation.

Fix: Verify if Better Auth supports hashed client secret storage. If not, document as accepted risk and ensure secrets are never logged.

Refs:


🟡 2) packages/agents-core/src/auth/auth-schema.ts:122 PKCE not enforced by default

Issue: The oauth_client table has requirePKCE: boolean as optional, defaulting to null/false. OAuth 2.1 mandates PKCE for all authorization code flows.

Why: Without PKCE enforcement, the authorization code flow is vulnerable to code interception attacks, especially for public clients.

Fix: Consider defaulting require_pkce to true in the schema or enforcing PKCE at the provider configuration level.

Refs:


Inline Comments:

  • 🟡 Minor: agents-manage-ui/src/app/login/page.tsx:56-59 OAuth flow detection relies solely on client_id presence

💭 Consider (1) 💭

💭 1) packages/agents-core/src/auth/auth-schema.ts OAuth tables lack indexes for common query patterns

Issue: New OAuth tables have FK references but no explicit indexes on commonly queried columns like userId, sessionId, or expiresAt. Postgres does not auto-create indexes for FK columns.

Why: Token lookup, revocation, and cleanup operations may degrade as data grows.

Fix: Add indexes for oauth_access_token(userId, sessionId, expiresAt), oauth_refresh_token(userId, sessionId, expiresAt), oauth_consent(userId).


🚫 REQUEST CHANGES

Summary: This PR introduces important OAuth 2.1/OIDC provider capability, but has blocking issues that should be addressed:

  1. Critical: The missing /consent page will break OAuth flows for clients requiring consent. Either create the page or ensure all initial clients use skipConsent: true with documentation.

  2. Major: The multi-org tenantId claim behavior needs explicit acknowledgment—consider scoping clients to orgs or creating a tracked issue for the org-picker flow.

  3. Major: Security-critical auth code should have test coverage. At minimum, add tests for customAccessTokenClaims and OIDC discovery endpoints.

  4. Major: Add .unique() constraint to oauth_refresh_token.token to match oauth_access_token.token.

The credential storage concerns (JWKS private keys, client secrets) may be Better Auth framework limitations—verify and document if so.


Discarded (5)
Location Issue Reason Discarded
auth.ts:96 disabledPaths: ['/token'] may conflict with existing flows Better Auth's oauth-provider uses /oauth2/token, not /token. Verified deviceAuthorization uses different path.
auth.ts:175 storeSessionInDatabase: true is a new setting Informational - this makes existing behavior explicit, not a change. Better Auth defaults to DB storage with drizzleAdapter.
createApp.ts:136-141 OIDC discovery endpoints lack explicit CORS Discovery endpoints are public metadata. Global CORS fallback is appropriate. Not a security issue.
.changeset Minor version bump may be incorrect Minor is appropriate for additive capability with schema migration. No breaking changes to existing API.
auth-schema.ts:127-145 Refresh tokens stored in plaintext While hashing would be more secure, this is likely a Better Auth framework requirement for token refresh flow. Marked as lower priority than JWKS keys.
Reviewers (6)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
pr-review-security-iam 7 2 0 0 2 0 3
pr-review-tests 6 1 0 0 0 0 5
pr-review-architecture 5 1 1 0 0 0 3
pr-review-appsec 5 1 0 0 1 0 3
pr-review-breaking-changes 5 0 0 0 0 0 5
pr-review-standards 3 1 0 0 2 0 0
Total 31 6 1 0 5 0 19

Note: High discard rate due to significant overlap across reviewers on the same issues (JWKS storage, consent page, multi-org claims raised by 3-4 reviewers each).

jwt(),
oauthProvider({
loginPage: `${env.INKEEP_AGENTS_MANAGE_UI_URL || 'http://localhost:3000'}/login`,
consentPage: `${env.INKEEP_AGENTS_MANAGE_UI_URL || 'http://localhost:3000'}/consent`,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 CRITICAL: Missing /consent page will break OAuth flows

Issue: The OAuth provider is configured with consentPage pointing to /consent, but no consent page exists in agents-manage-ui/src/app/consent/. OAuth flows requiring user consent will fail with a 404.

Why: When Better Auth redirects to the consent page during an OAuth authorization flow, users will hit a 404 error. While skipConsent: true can be set on individual OAuth clients, any third-party client registration or explicit consent requirement will break the flow entirely. This is a blocking issue for OAuth flows that require consent.

Fix: Either:

  1. Create the consent page at agents-manage-ui/src/app/consent/page.tsx that displays requested scopes and allows approve/deny
  2. If consent is not needed for initial use cases (internal clients only), ensure all registered clients have skipConsent: true and document this limitation prominently

Refs:

Comment on lines +210 to +217
customAccessTokenClaims: async ({ user }) => {
if (!user) return {};
const org = await getInitialOrganization(config.dbClient, user.id);
return {
'https://inkeep.com/tenantId': org?.id ?? undefined,
'https://inkeep.com/email': user.email,
};
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 MAJOR: Multi-org tenantId claim uses non-deterministic "first org" heuristic

Issue: The customAccessTokenClaims callback uses getInitialOrganization() which returns the user's first organization by membership creation date. For multi-org users, the access token will contain their earliest org's tenantId regardless of the intended authorization context.

Why: OAuth access tokens are long-lived and cached by clients. If a user belongs to multiple orgs and the token embeds the wrong tenantId:

  • Downstream consumers may authorize actions against the wrong tenant
  • Data access could occur under incorrect tenant permissions
  • This is acknowledged in the PR description but represents significant technical debt

Fix: Consider these options:

  1. Short-term: Scope OAuth clients to specific organizations and derive tenantId from the client registration rather than user membership (query oauth_client for its associated org)
  2. Medium-term: Add an org selection step during the OAuth consent flow that captures the selected org in session state
  3. At minimum: Add a TODO/FIXME comment in code and create a tracked issue for the multi-org org-picker flow

Refs:

Comment on lines +86 to +92
export const jwks = pgTable('jwks', {
id: text('id').primaryKey(),
publicKey: text('public_key').notNull(),
privateKey: text('private_key').notNull(),
createdAt: timestamp('created_at').notNull(),
expiresAt: timestamp('expires_at'),
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 MAJOR: JWT private keys stored in plaintext in database

Issue: The jwks table stores JWT signing private keys in plaintext (privateKey: text('private_key').notNull()). These keys can sign access tokens for any OAuth client.

Why: If the database is compromised (SQL injection, backup leak, insider access), attackers can forge arbitrary access tokens and impersonate any user. This is a high-value target for credential theft.

Fix: This may be a Better Auth framework limitation, but consider:

  1. Verify: Check if Better Auth supports custom key storage providers or encrypted storage adapters
  2. Encrypt at rest: Use application-level encryption with a key stored in a secret manager (AWS KMS, HashiCorp Vault)
  3. Document: If this is an accepted risk, document the security assumption and ensure database encryption at rest is enabled
  4. Long-term: Consider using a dedicated key management service for JWT signing

Refs:


export const oauthRefreshToken = pgTable('oauth_refresh_token', {
id: text('id').primaryKey(),
token: text('token').notNull(),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 MAJOR: Missing unique constraint on oauth_refresh_token.token

Issue: The oauth_refresh_token table has a token column marked as NOT NULL but lacks a unique constraint, unlike oauth_access_token.token which has .unique().

Why: Refresh tokens must be unique to prevent token collision attacks. Without a unique constraint:

  • Duplicate refresh tokens could exist in the database
  • Token lookups could return the wrong record
  • Revoking one token might leave a duplicate active

Fix:

Suggested change
token: text('token').notNull(),
token: text('token').notNull().unique(),

Refs:

Comment on lines +56 to +59
const isOAuthFlow = searchParams.has('client_id');
const oauthAuthorizeUrl = isOAuthFlow
? `${PUBLIC_INKEEP_AGENTS_API_URL}/api/auth/oauth2/authorize?${searchParams.toString()}`
: null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Minor: OAuth flow detection relies solely on client_id presence

Issue: The OAuth flow is detected by checking searchParams.has('client_id') only. However, client_id could appear in query params for other reasons (analytics, deep links, etc.), which could incorrectly trigger OAuth redirect behavior.

Why: If a non-OAuth URL contains client_id in the query string, authenticated users would be unexpectedly redirected to /api/auth/oauth2/authorize instead of their intended destination.

Fix: Consider checking for multiple OAuth-specific parameters to reduce false positives:

Suggested change
const isOAuthFlow = searchParams.has('client_id');
const oauthAuthorizeUrl = isOAuthFlow
? `${PUBLIC_INKEEP_AGENTS_API_URL}/api/auth/oauth2/authorize?${searchParams.toString()}`
: null;
const isOAuthFlow = searchParams.has('client_id') && searchParams.has('redirect_uri');
const oauthAuthorizeUrl = isOAuthFlow
? `${PUBLIC_INKEEP_AGENTS_API_URL}/api/auth/oauth2/authorize?${searchParams.toString()}`
: null;

Refs:

@github-actions github-actions Bot deleted a comment from claude Bot Apr 10, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 10, 2026

Preview URLs

Use these stable preview aliases for testing this PR:

These point to the same Vercel preview deployment as the bot comment, but they stay stable and easier to find.

Raw Vercel deployment URLs

Add /consent page that Better Auth's oauth-provider plugin redirects to
when an OAuth client requires user consent. Displays the requesting
application name and requested scopes, and POSTs the user's decision
back to the /api/auth/oauth2/consent endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

PR Review Summary

(0) Total Issues | Risk: Low (delta only)

This is a delta review scoped to the changes since the last automated review (b059082391d34fa). The delta adds 2 files implementing the OAuth consent page that was flagged as CRITICAL missing in the prior review.


✅ Critical Issue Resolved

The missing /consent page that would have broken OAuth flows has been implemented. The consent page:

  • ✅ Properly handles loading and unauthenticated states
  • ✅ Displays requested scopes with human-readable labels
  • ✅ Sends consent decisions to /api/auth/oauth2/consent with credentials included
  • ✅ Handles redirect responses and error states correctly
  • ✅ Uses existing UI component library (Card, Button, Alert, ShieldCheck icon)
  • ✅ Wraps in Suspense for client-side useSearchParams()

💭 Consider (2) 💭

Inline Comments:

  • 💭 Consider: consent/layout.tsx:8 Use LayoutProps<'/consent'> to match codebase pattern
  • 💭 Consider: consent/page.tsx:70-77 Redirect unauthenticated users to login with OAuth params preserved

🕐 Pending Recommendations (Prior Review)

The following issues from prior reviews remain applicable (not addressed in this delta):


💡 APPROVE WITH SUGGESTIONS

Summary: The critical missing consent page has been added. The implementation is solid and follows existing UI patterns. The two minor suggestions (layout typing, unauthenticated redirect) are quality-of-life improvements, not blockers. The prior major/minor issues from earlier reviews (plaintext keys, multi-org claims, missing tests, missing indexes) remain applicable and should be tracked for follow-up.


Reviewers (1)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
orchestrator 2 0 2 0 2 5 0
Total 2 0 2 0 2 5 0

Note: Delta review — sub-reviewers not dispatched for 2-file UI-only change that resolves prior critical feedback.

title: 'Authorize Application',
} satisfies Metadata;

export default function Layout({ children }: { children: ReactNode }) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💭 Consider: Layout typing pattern

Issue: The layout uses { children: ReactNode } instead of the codebase's typed LayoutProps<'/consent'> pattern.

Why: Other layouts in the codebase use LayoutProps from the Next.js typed routes system (see login/layout.tsx, no-organization/layout.tsx), which provides better type safety for route-specific props.

Fix:

export default function Layout({ children }: LayoutProps<'/consent'>) {
  return children;
}

Refs:

You must be signed in to authorize an application.
</p>
</div>
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💭 Consider: Redirect unauthenticated users to login

Issue: When an unauthenticated user lands on the consent page, they see a static message: "You must be signed in to authorize an application." However, Better Auth may pass OAuth query parameters (like client_id, redirect_uri) that would be lost.

Why: If a user's session expires mid-flow or they navigate directly to /consent, they should be redirected to /login with the OAuth params preserved so the flow can resume after authentication.

Fix: Consider redirecting to login with the OAuth params:

if (!isAuthenticated) {
  const loginUrl = `/login?${searchParams.toString()}`;
  window.location.href = loginUrl;
  return null;
}

This mirrors the pattern in login/page.tsx where OAuth params are preserved through the authentication flow.

Refs:

- Use LayoutProps<'/consent'> to match codebase convention
- Redirect unauthenticated users to /login with OAuth query params
  preserved so the flow resumes automatically after sign-in
- Use window.location.assign() instead of href assignment to satisfy
  React Compiler

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@vercel vercel Bot temporarily deployed to Preview – agents-docs April 10, 2026 22:06 Inactive
@socket-security
Copy link
Copy Markdown

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

PR Review Summary

(0) Total Issues | Risk: Low (delta only)

This is a delta review scoped to changes since the last automated review (391d34fabb3622604). The single commit addresses both "Consider" items from the prior review.


✅ Prior Feedback Addressed

Both suggestions from the previous review have been implemented correctly:

Prior Suggestion Resolution
💭 Use LayoutProps<'/consent'> to match codebase pattern layout.tsx now uses typed LayoutProps<'/consent'> instead of { children: ReactNode }
💭 Redirect unauthenticated users to login with OAuth params preserved page.tsx now uses useEffect to detect unauthenticated state and router.replace() to /login?${searchParams.toString()}

Implementation quality:

  • The redirect logic correctly waits for isSessionLoading to complete before checking auth state
  • OAuth query params are preserved through searchParams.toString()
  • Loading state shows spinner while auth check and redirect are in progress
  • Uses router.replace for the redirect (appropriate for this flow — no back-button to consent page)

🕐 Pending Recommendations (Prior Reviews)

The following issues from prior reviews remain applicable to the overall PR (not introduced in this delta):


💡 APPROVE WITH SUGGESTIONS

Summary: The delta cleanly addresses both prior "Consider" suggestions. The OAuth consent page now properly redirects unauthenticated users to login while preserving OAuth query params, and follows the codebase's typed layout pattern. The outstanding Major/Minor issues from earlier reviews (plaintext keys, multi-org claims, missing tests, missing indexes, refresh token unique constraint) remain applicable to the overall PR and should be tracked for follow-up or addressed before merge.


Reviewers (1)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
orchestrator 0 0 0 0 0 5 0
Total 0 0 0 0 0 5 0

Note: Delta review — sub-reviewers not dispatched for 2-file UI-only change that addresses prior feedback.

@github-actions github-actions Bot deleted a comment from claude Bot Apr 10, 2026
@omar-inkeep omar-inkeep added this pull request to the merge queue Apr 10, 2026
Merged via the queue into main with commit d1e18a8 Apr 10, 2026
26 checks passed
@omar-inkeep omar-inkeep deleted the feat/oauth-provider-setup branch April 10, 2026 22:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant