Skip to content

feat: SSO configuration and auth method management#2686

Merged
omar-inkeep merged 12 commits intomainfrom
feat/org-sso-login
Mar 19, 2026
Merged

feat: SSO configuration and auth method management#2686
omar-inkeep merged 12 commits intomainfrom
feat/org-sso-login

Conversation

@omar-inkeep
Copy link
Contributor

@omar-inkeep omar-inkeep commented Mar 13, 2026

Summary

  • SSO provider configuration UI — register, toggle, edit, and remove SAML/OIDC providers (Okta, Entra, etc.) from the settings page with full allowedAuthMethods management
  • Domain-filtered login flow — auth lookup API resolves email → orgs → methods; login page and accept-invitation page filter SSO options by the user's email domain
  • Auto-provisioning with invitation precedenceshouldAutoProvision respects the org toggle and skips auto-provision when a pending invitation exists (preserving explicit roles like Admin)
  • Members page — extracted from settings into its own /members route with invitation management, role assignment, and project access dialogs
  • UX improvements — empty state messages when no auth methods match, UI guard preventing disabling all methods, no-org page shows pending invitations, password reset and forgot-password page polish

Packages changed

Package Bump Summary
agents-core minor Auth config utils, SSO schema additions, getFilteredAuthMethodsForEmail, shouldAutoProvision with invitation check, new runtime migration
agents-api minor Auth lookup route, invitation verify domain filtering, factory/index cleanup
agents-manage-ui minor Login page rewrite, SSO config component, auth method toggles, members page, accept-invitation filtering, no-org pending invitations

Key files

  • packages/agents-core/src/auth/auth.ts — Better Auth config with organizationProvisioning: { disabled: true } and custom provisionUser
  • packages/agents-core/src/auth/auth-config-utils.tsshouldAutoProvision, parseAllowedAuthMethods, auth method utilities
  • packages/agents-core/src/data-access/runtime/organizations.tsgetFilteredAuthMethodsForEmail, getAllowedAuthMethods, SSO provider queries
  • agents-api/src/domains/manage/routes/authLookup.ts — new unauthenticated /manage/api/auth-lookup endpoint
  • agents-manage-ui/src/app/login/page.tsx — multi-step login (email → org picker → method picker)
  • agents-manage-ui/src/components/settings/sso-configuration.tsx — SSO provider CRUD UI
  • agents-manage-ui/src/components/settings/auth-method-configuration.tsx — auth method toggles with last-method guard

Test plan

  • Parts 1–5 of SSO Login & Invite Test Cases.md manually tested
  • Part 6 (Slack work app integration) — not yet tested
  • CI passes (lint, typecheck, unit tests)

Made with Cursor

…red login flows

Add end-to-end SSO support including:
- SSO provider configuration UI (SAML/OIDC via Okta, Entra, etc.)
- Auth method toggles (email-password, Google, per-SSO-provider)
- Domain-filtered method picker on login and invitation pages
- Auto-provisioning with pending-invitation precedence
- Members page extracted from settings with invitation management
- No-org page shows pending invitations for unauthenticated members
- UI guard preventing disabling all auth methods
- Auth lookup API for email-based org/method discovery

Made-with: Cursor
@vercel
Copy link

vercel bot commented Mar 13, 2026

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

Project Deployment Actions Updated (UTC)
agents-api Ready Ready Preview, Comment Mar 19, 2026 3:02pm
agents-manage-ui Ready Ready Preview, Comment Mar 19, 2026 3:02pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
agents-docs Skipped Skipped Mar 19, 2026 3:02pm

Request Review

@changeset-bot
Copy link

changeset-bot bot commented Mar 13, 2026

🦋 Changeset detected

Latest commit: 1cc7ccc

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

Copy link
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.

Thorough review of the SSO configuration and auth method management PR. This is a large, well-structured feature touching auth core, data access, API routes, frontend UI, and build config. Found one critical runtime bug (null reference in SSO auto-provisioning), one likely broken package export path, and several security/performance concerns that should be addressed before merging.

Critical:

  • provisionUser in sso() calls auth.api.addMember(...) but auth resolves to the module-level null sentinel — will throw at runtime during SSO auto-provisioning.
  • The ./auth package export points import to ./dist/auth.js but tsdown with unbundle: true outputs to ./dist/auth/auth.js.

High:

  • The unauthenticated /api/auth-lookup endpoint leaks org names/slugs/IDs for any email — email enumeration and org info disclosure risk.
  • parseAllowedAuthMethods uses an unsafe as cast on untrusted JSON from the DB.
  • No rate limiting on the unauthenticated auth-lookup endpoint.

Medium:

  • getTrustedOrigins fires OIDC discovery fetches for every SSO provider on every auth request with no caching.
  • N+1 query pattern in getAuthLookupForEmail — redundant org fetches.
  • Duplicate SpiceDB sync in beforeAddMember + beforeAcceptInvitation.
  • beforeAddMember now hard-throws on SpiceDB failure (behavior change from soft-fail).

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow runpullfrog.com𝕏

Comment on lines +225 to +231
await auth.api.addMember({
body: {
userId: user.id,
organizationId: provider.organizationId,
role: 'member',
},
});
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug (critical): auth here resolves to the module-level export const auth = null as unknown as AuthInstance (line 431), not the betterAuth instance being constructed. The old code had const auth = betterAuth({...}); return auth; so the closure captured the real instance, but the refactor changed it to return betterAuth({...}) as unknown as AuthInstance; — there is no local auth variable in scope.

This will throw TypeError: Cannot read properties of null (reading 'api') at runtime when SSO auto-provisioning fires.

Fix: restore a local variable assignment:

const auth = betterAuth({ ... });
// ... (provisionUser closure now captures the real instance)
return auth as unknown as AuthInstance;

Comment on lines +82 to +83
"types": "./src/auth/auth.ts",
"import": "./dist/auth.js"
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: The import path ./dist/auth.js is almost certainly wrong. With unbundle: true in tsdown, entry src/auth/auth.ts preserves directory structure and outputs to dist/auth/auth.js — consistent with every other ./auth/* export in this file (e.g., ./dist/auth/auth-schema.js, ./dist/auth/auth-types.js). This will cause ERR_MODULE_NOT_FOUND for consumers importing @inkeep/agents-core/auth.

Also: types points to raw .ts source (./src/auth/auth.ts) instead of a .d.ts. This works in-monorepo but will break for published npm consumers since src/ is not in the files array. The second tsdown entry has dts: false and the first excludes auth.ts, so no .d.ts is generated at all.

Comment on lines +94 to +102
export function parseAllowedAuthMethods(raw: string | null | undefined): AllowedAuthMethod[] {
if (!raw) return [{ method: 'email-password' }];
try {
const parsed = JSON.parse(raw) as AllowedAuthMethod[];
if (!Array.isArray(parsed)) return [{ method: 'email-password' }];
return parsed;
} catch {
return [{ method: 'email-password' }];
}
Copy link
Contributor

Choose a reason for hiding this comment

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

JSON.parse(raw) as AllowedAuthMethod[] trusts the shape of data stored in the DB column without validation. If the JSON is malformed (e.g., an SSO entry missing enabled or autoProvision), downstream code like shouldAutoProvision reads undefined for those fields and relies on falsy coercion.

You already have Zod schemas in this file. Consider validating with a schema and falling back to the default on failure, rather than casting.

Comment on lines +68 to +91
try {
const providers = await dbClient
.select({ issuer: authSchema.ssoProvider.issuer })
.from(authSchema.ssoProvider);

const issuerOrigins = providers
.map((p) => {
try {
return new URL(p.issuer).origin;
} catch {
return null;
}
})
.filter((origin): origin is string => origin !== null);

const discoveryResults = await Promise.all(
providers.map((p) => fetchOidcDiscoveryOrigins(p.issuer))
);

return [...staticOrigins, ...dynamicOrigins, ...issuerOrigins, ...discoveryResults.flat()];
} catch {
return [...staticOrigins, ...dynamicOrigins];
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Performance: getTrustedOrigins is called on every auth request (it's the trustedOrigins callback). It queries the DB for all SSO providers and then fires N outbound OIDC discovery HTTP fetches (each with a 5s timeout) on every invocation. With 5 SSO providers, this adds 5 HTTP round-trips to every auth request.

Consider caching the discovery results with a TTL (e.g., 5-10 minutes keyed by issuer), or computing the trusted origins list at startup and refreshing when providers change.

Comment on lines +376 to +391
const orgRow = await db
.select({
id: organization.id,
name: organization.name,
slug: organization.slug,
allowedAuthMethods: organization.allowedAuthMethods,
preferredAuthMethod: organization.preferredAuthMethod,
})
.from(organization)
.where(eq(organization.id, orgId))
.limit(1);

const org = orgRow[0];
if (!org) continue;

const allowed = await getAllowedAuthMethods(db)(orgId);
Copy link
Contributor

Choose a reason for hiding this comment

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

N+1 query: For each orgId from the SSO domain lookup, line 376-386 queries organization to get allowedAuthMethods (among other fields), then getAllowedAuthMethods(db)(orgId) at line 391 re-queries the same table for the same orgId just to read allowedAuthMethods. The data is already available in org.allowedAuthMethods — call parseAllowedAuthMethods(org.allowedAuthMethods) directly.

The same pattern repeats at line 424 for membership orgs — m.allowedAuthMethods is already fetched in the join at line 414.

Comment on lines +178 to +180
setError(null);
setState({ step: 'org-picker', orgs: [] });
handleEmailContinue({ preventDefault: () => {} } as React.FormEvent);
Copy link
Contributor

Choose a reason for hiding this comment

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

Two issues with handleBack from method-picker (multiOrg):

  1. Fragile fake event: handleEmailContinue is called with { preventDefault: () => {} } as React.FormEvent. If it ever accesses other event properties, this will throw. Extract the async lookup logic into a separate function (e.g., performEmailLookup()) that both handleEmailContinue and handleBack call.

  2. Empty org list flash: State is set to { step: 'org-picker', orgs: [] } synchronously, then the lookup fires async. During the network round-trip, the user sees the org-picker with zero org buttons. Either stay on method-picker with a loading indicator, or transition to the email step's spinner instead.

Comment on lines +148 to +153
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button variant="destructive" onClick={handleRemove}>
Remove
</Button>
Copy link
Contributor

Choose a reason for hiding this comment

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

The "Remove" button has no loading/disabled state. handleRemove is async (deletes provider → fetches org → updates allowedAuthMethods), but the button remains clickable throughout. A user can click it multiple times, triggering duplicate deletes and a race on the allowedAuthMethods update. Add an isRemoving state and disable both buttons during the operation.

Comment on lines 255 to +258
);
}

if (!orgSettings?.preferredAuthMethod) {
throw new Error(
`Organization ${data.organizationId} does not have a preferredAuthMethod configured`
);
}
const resolvedMethod = data.authMethod || orgSettings.preferredAuthMethod || 'email-password';
Copy link
Contributor

Choose a reason for hiding this comment

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

The fallback chain data.authMethod || orgSettings.preferredAuthMethod || 'email-password' could produce a method that isn't in the org's allowedAuthMethods list. For example, a caller could pass authMethod: 'google' for an org that only allows email-password. Since allowedAuthMethods is already fetched here, consider validating that resolvedMethod is permitted.

# Exact pin: better-auth ships breaking changes in patch releases (PR #7363).
better-auth: 1.4.19
"@better-auth/sso": 1.4.19
better-auth: 1.5.5
Copy link
Contributor

Choose a reason for hiding this comment

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

The previous version had a comment explaining why better-auth versions are exact-pinned: # Exact pin: better-auth ships breaking changes in patch releases (PR #7363). The comment was removed but the concern still applies. Consider keeping it so future contributors don't add ^ prefixes.

Copy link
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

(12) Total Issues | Risk: High

🔴❗ Critical (2) ❗🔴

🔴 1) auth.ts:225 SSO auto-provisioning will crash due to null reference

Issue: The provisionUser callback references auth.api.addMember(), but auth is the module-level export defined on line 431 as null as unknown as AuthInstance. When an SSO user triggers auto-provisioning, this will throw TypeError: Cannot read properties of null (reading 'api').

Why: The callback executes after createAuth() returns, so auth cannot reference the instance being created. This completely breaks SSO auto-provisioning for all users — any organization with autoProvision: true will fail when new SSO users attempt to sign in.

Fix: Use the database client directly instead of the Better Auth API:

import { addUserToOrganization } from '../data-access/runtime/organizations';

// In provisionUser callback:
await addUserToOrganization(config.dbClient)({
  userId: user.id,
  organizationId: provider.organizationId,
  role: 'member',
});

Refs:

🔴 2) package.json:81-84 Build output path mismatch will cause module resolution failures

Issue: The ./auth export's import field points to ./dist/auth.js, but with unbundle: true in tsdown.config.ts, the actual output is ./dist/auth/auth.js (directory structure is preserved).

Why: Consumers importing from @inkeep/agents-core/auth will get "module not found" errors at runtime. This is a ship-blocking build configuration issue.

Fix: Update the import path to match the actual build output:

"./auth": {
  "types": "./src/auth/auth.ts",
  "import": "./dist/auth/auth.js"
}

Refs:

Inline Comments:

  • 🔴 Critical: auth.ts:225 SSO auto-provisioning null reference
  • 🔴 Critical: package.json:81-84 Build output path mismatch

🟠⚠️ Major (6) 🟠⚠️

🟠 1) authLookup.ts:21-43 Auth lookup endpoint enables organization enumeration

Issue: The new unauthenticated /manage/api/auth-lookup endpoint returns organization names, slugs, IDs, and auth methods for any email address. This enables reconnaissance attacks to enumerate organizations, confirm user existence, and fingerprint SSO providers.

Why: While necessary for the email-first login UX, this creates an information disclosure surface without rate limiting or anti-enumeration measures.

Fix: Add rate limiting middleware and consider returning minimal data (just organizationId and methods) for SSO-domain-matched orgs where the user is not already a member.

Refs:

🟠 2) auth-types.ts:94-103 JSON parsing uses unsafe type assertion without validation

Issue: parseAllowedAuthMethods casts parsed JSON directly to AllowedAuthMethod[] without runtime validation. Invalid JSON structures will pass through and cause downstream failures.

Why: Database corruption or manual edits could inject invalid data that the type system trusts but runtime code doesn't handle, causing cryptic errors in auth flows.

Fix: Add Zod validation using a discriminated union schema that enforces required fields per method type.

Refs:

🟠 3) auth-config-utils.ts:207-209 registerSSOProvider silently swallows database errors

Issue: The catch block logs errors but doesn't re-throw or return failure status. Callers cannot distinguish success from failure.

Why: Admins configuring SSO would see success UI while the provider was never saved, leading to users unable to authenticate with no clear diagnosis path.

Fix: Return { success: boolean; error?: string } to let callers handle failures appropriately.

🟠 4) login/page.tsx:123-143 Silent fallback to password step on auth lookup errors

Issue: Both the non-OK response handler and catch block silently fall back to password login without logging or user notification.

Why: SSO-only users will see a password form they cannot use when the auth-lookup API fails. No error trace makes debugging difficult.

Fix: Log errors and show user-facing messages for 5xx errors instead of silent fallback.

🟠 5) scope Missing test coverage for critical auth functions

Issue: The PR deletes auth.test.ts without adding tests for the new auth logic. Functions without test coverage include:

  • shouldAutoProvision (security-critical authorization decision)
  • getFilteredAuthMethodsForEmail (domain filtering logic)
  • getAuthLookupForEmail (login flow entry point)
  • parseAllowedAuthMethods (JSON parsing)

Why: These functions implement security-critical authorization and authentication logic. Regressions could cause unauthorized access, broken login flows, or role assignment bugs.

Fix: Add unit tests covering the key code paths, especially the invitation precedence check in shouldAutoProvision.

Refs:

🟠 6) sso-configuration.tsx:151-152 Remove button lacks loading state

Issue: The destructive "Remove" button has no disabled state or spinner during async operation, allowing double-clicks.

Why: Double-clicking delete could cause race conditions or confusing error states.

Fix: Add isRemoving state and disable button during the async operation.

Inline Comments:

  • 🟠 Major: authLookup.ts:21-43 Organization enumeration
  • 🟠 Major: auth-types.ts:94-103 Unsafe JSON parsing
  • 🟠 Major: auth-config-utils.ts:207-209 Silent error swallowing
  • 🟠 Major: login/page.tsx:123-143 Silent fallback on errors
  • 🟠 Major: sso-configuration.tsx:151-152 Missing loading state

🟡 Minor (4) 🟡

🟡 1) invite-member-dialog.tsx:191 Debug console.log left in production code

Issue: console.log('invitationResults', invitationResults) should be removed.

🟡 2) accept-invitation/page.tsx:347-351 Empty state message could be more helpful

Issue: The "no sign-in methods available" message doesn't explain domain-based filtering or show the user's domain.

Fix: Include the domain in the message so users understand why SSO isn't available for their email.

🟡 3) members-table.tsx:282 Empty TableHead lacks accessible content

Issue: The actions column header has no screen reader text.

Fix: Add <span className="sr-only">Actions</span>.

🟡 4) accept-invitation/page.tsx:539 Inconsistent aria-hidden on decorative icon

Issue: This AlertCircleIcon lacks aria-hidden unlike the same icon at line 369.

Fix: Add aria-hidden for consistency.

Inline Comments:

  • 🟡 Minor: invite-member-dialog.tsx:191 Debug console.log
  • 🟡 Minor: accept-invitation/page.tsx:347-351 Unhelpful empty state
  • 🟡 Minor: members-table.tsx:282 Missing sr-only text
  • 🟡 Minor: accept-invitation/page.tsx:539 Missing aria-hidden

💭 Consider (4) 💭

💭 1) changeset Version bump may need adjustment

Issue: The changeset specifies minor bumps, but this PR removes public exports (createAuth0Provider, createOIDCProvider from agents-api). Removing exported functions is typically a breaking change requiring a major bump.

Fix: Either restore the exports with deprecation warnings, or bump to major version.

💭 2) auth-types.ts:10-17 MethodOption schema allows illegal states

Issue: The Zod schema permits { method: 'sso' } without providerId, contradicting runtime expectations. Consider using a discriminated union.

💭 3) auth-types.ts:74-81 SSOProviderConfig permits neither OIDC nor SAML config

Issue: Both oidcConfig and samlConfig are optional, allowing semantically invalid providers.

💭 4) organizations.ts:375-395 N+1 query pattern in getAuthLookupForEmail

Issue: Loop makes individual database queries for each organization. Consider batching with inArray().

🧹 While You're Here (1) 🧹

🧹 1) scope SSO documentation is missing

Issue: This PR introduces significant SSO functionality but no customer-facing documentation was added. Self-hosted users won't know how to configure SSO providers.

Fix: Add documentation covering: (1) SSO setup guide for OIDC providers, (2) Auth method configuration, (3) Domain filtering behavior.


🚫 REQUEST CHANGES

Summary: This PR introduces valuable SSO configuration and auth method management features, but has two critical blocking issues that must be fixed before merge:

  1. SSO auto-provisioning is broken — The auth.api.addMember() call references a null module export, causing runtime crashes for all SSO users attempting to join organizations.

  2. Build output path is wrong — The package.json export points to a non-existent file path, breaking all consumers of @inkeep/agents-core/auth.

Once these are fixed, the remaining Major issues around information disclosure, error handling, and test coverage should be addressed to ensure production reliability.

Discarded (8)
Location Issue Reason Discarded
auth-schema.ts:92 Missing FK constraint on ssoProvider.organizationId Valid observation but pre-existing schema design, not introduced by this PR
organizations.ts:331-356 allowedMethodsToMethodOptions lacks tests Low severity — pure function with straightforward logic
device/page.tsx:308 Suspense fallback lacks accessible loading Pattern exists elsewhere in codebase, not introduced by this PR
link/page.tsx:204 AlertCircleIcon lacks aria-hidden Same pattern as other files, pre-existing
auth-method-configuration.tsx:171,215 Icons lack aria-hidden Lucide icons may set this by default, needs verification
login/page.tsx:192 Loading spinner lacks accessible text Valid but minor, pattern exists elsewhere
login/page.tsx:443-445 MethodIcon lacks aria-hidden Lucide icons may set this by default
sso-configuration.tsx:188 useMemo vs useState for providerId Working correctly, nitpick
Reviewers (13)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
pr-review-standards 1 1 0 0 1 0 0
pr-review-security-iam 6 1 2 0 1 0 2
pr-review-product 6 1 0 1 1 0 3
pr-review-consistency 9 0 0 0 0 0 9
pr-review-breaking-changes 5 0 1 0 0 0 4
pr-review-frontend 15 1 0 0 3 0 11
pr-review-tests 6 1 0 0 0 0 5
pr-review-types 4 1 2 0 1 0 0
pr-review-errors 15 2 0 0 2 0 11
pr-review-architecture 5 0 1 0 0 0 4
pr-review-devops 7 1 1 0 1 0 4
pr-review-docs 1 0 0 1 0 0 0
pr-review-precision 7 1 0 0 1 0 5
Total 87 10 7 2 11 0 58

return;
}

await auth.api.addMember({
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 CRITICAL: SSO auto-provisioning will crash due to null reference

Issue: The provisionUser callback references auth.api.addMember(), but auth is the module-level export defined on line 431 as null as unknown as AuthInstance. When an SSO user triggers auto-provisioning, this will throw TypeError: Cannot read properties of null (reading 'api').

Why: The callback executes after createAuth() returns, so auth cannot reference the instance being created. This breaks the entire SSO auto-provisioning flow.

Fix: Insert the member directly via the database client instead of using the Better Auth API:

Suggested change
await auth.api.addMember({
await addUserToOrganization(config.dbClient)({
userId: user.id,
organizationId: provider.organizationId,
role: 'member',
});

Note: You'll need to import addUserToOrganization from '../data-access/runtime/organizations'.

Refs:

Comment on lines 81 to 84
"./auth": {
"types": "./dist/auth/auth.d.ts",
"import": "./dist/auth/auth.js"
"types": "./src/auth/auth.ts",
"import": "./dist/auth.js"
},
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 CRITICAL: Build output path mismatch will cause module resolution failures

Issue: The import field points to ./dist/auth.js, but with unbundle: true in tsdown.config.ts, the actual output path is ./dist/auth/auth.js (directory structure is preserved).

Why: Consumers importing from @inkeep/agents-core/auth will get "module not found" errors at runtime because the file doesn't exist at the specified path.

Fix:

Suggested change
"./auth": {
"types": "./dist/auth/auth.d.ts",
"import": "./dist/auth/auth.js"
"types": "./src/auth/auth.ts",
"import": "./dist/auth.js"
},
"./auth": {
"types": "./src/auth/auth.ts",
"import": "./dist/auth/auth.js"
},

Refs:

Comment on lines +94 to +103
export function parseAllowedAuthMethods(raw: string | null | undefined): AllowedAuthMethod[] {
if (!raw) return [{ method: 'email-password' }];
try {
const parsed = JSON.parse(raw) as AllowedAuthMethod[];
if (!Array.isArray(parsed)) return [{ method: 'email-password' }];
return parsed;
} catch {
return [{ method: 'email-password' }];
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🟠 MAJOR: JSON parsing uses unsafe type assertion without validation

Issue: The parseAllowedAuthMethods function casts parsed JSON directly to AllowedAuthMethod[] without runtime validation. Invalid JSON like [{ "method": "sso" }] (missing required SSO fields) will pass through and cause downstream failures.

Why: Database corruption, manual edits, or future schema changes could inject invalid data that the type system trusts but runtime code doesn't handle.

Fix: Add Zod validation:

const allowedAuthMethodSchema = z.discriminatedUnion('method', [
  z.object({ method: z.literal('email-password') }),
  z.object({ method: z.literal('google') }),
  z.object({
    method: z.literal('sso'),
    providerId: z.string(),
    displayName: z.string(),
    autoProvision: z.boolean(),
    enabled: z.boolean(),
  }),
]);

export function parseAllowedAuthMethods(raw: string | null | undefined): AllowedAuthMethod[] {
  if (!raw) return [{ method: 'email-password' }];
  try {
    const parsed = JSON.parse(raw);
    const result = z.array(allowedAuthMethodSchema).safeParse(parsed);
    if (!result.success) return [{ method: 'email-password' }];
    return result.data;
  } catch {
    return [{ method: 'email-password' }];
  }
}

Refs:

Comment on lines +123 to +143
if (!response.ok) {
setState({ step: 'password' });
setIsLookingUp(false);
return;
}

const [formData, setFormData] = useState({
email: '',
password: '',
});
const result = authLookupResponseSchema.parse(await response.json());
const orgs = result.organizations;

const handleSubmit = async (e: React.FormEvent) => {
if (orgs.length === 0) {
setState({ step: 'password' });
} else if (orgs.length === 1) {
setState({ step: 'method-picker', org: orgs[0], multiOrg: false });
} else {
setState({ step: 'org-picker', orgs });
}
} catch {
setState({ step: 'password' });
} finally {
setIsLookingUp(false);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🟠 MAJOR: Silent fallback to password step on auth lookup errors

Issue: Both the non-OK response handler (line 124) and catch block (line 139) silently fall back to the password step without logging or user notification. Users won't know why they can't find their SSO options.

Why: If the auth-lookup API is misconfigured, rate-limited, or down, SSO-only users will see a password form they cannot use. Debugging this is difficult since there's no trace of what went wrong.

Fix: Log the error and distinguish server errors from "no orgs found":

if (!response.ok) {
  console.error('[login] Auth lookup failed:', response.status);
  if (response.status >= 500) {
    setError('Unable to look up sign-in options. Please try again.');
    setIsLookingUp(false);
    return;
  }
  // 4xx likely means no org found - fallback to password is reasonable
  setState({ step: 'password' });
  setIsLookingUp(false);
  return;
}

And in the catch block:

} catch (err) {
  console.error('[login] Auth lookup error:', err);
  setState({ step: 'password' });
}

Refs:

Comment on lines +151 to +152
<Button variant="destructive" onClick={handleRemove}>
Remove
Copy link
Contributor

Choose a reason for hiding this comment

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

🟠 MAJOR: Remove button lacks loading state during async operation

Issue: The destructive "Remove" button has no disabled state or spinner during the async handleRemove operation. Users can click multiple times, potentially causing duplicate deletion attempts.

Why: Per web-design-guidelines, submit buttons should show a spinner during requests. Double-clicking a delete button could cause race conditions or confusing error states.

Fix: Add loading state:

const [isRemoving, setIsRemoving] = useState(false);

const handleRemove = async () => {
  if (!provider) return;
  setIsRemoving(true);
  try {
    // ... existing logic
  } finally {
    setIsRemoving(false);
  }
};

// In the button:
<Button variant="destructive" onClick={handleRemove} disabled={isRemoving}>
  {isRemoving ? (
    <><Loader2 className="mr-2 h-4 w-4 animate-spin" />Removing...</>
  ) : (
    'Remove'
  )}
</Button>

Refs:

@github-actions github-actions bot deleted a comment from claude bot Mar 13, 2026
@itoqa
Copy link

itoqa bot commented Mar 13, 2026

Ito Test Report ❌

15 test cases ran. 14 passed, 1 failed.

🔍 Verification focused on authentication routing, invitation handling, and SSO lifecycle behavior. Most covered flows behaved correctly. One production-code defect is confirmed in SSO provider removal: removing a provider can persist an empty allowedAuthMethods configuration and leave users with no available sign-in method.

✅ Passed (14)
Test Case Summary Timestamp Screenshot
ROUTE-3 Unknown email transitioned to password form, and Change navigated back to editable email step without crash. 2:01 ROUTE-3_2-01.png
ROUTE-4 Org picker rendered multiple organizations, and Back/select transitions kept selected organization and method-picker state coherent without stale state. 11:41 ROUTE-4_11-41.png
ROUTE-7 Invitation accept and decline both executed successfully: accept transitioned invitation to accepted with member creation, decline transitioned invitation to rejected, and post-action verify checks confirmed invitations were no longer pending. 25:48 ROUTE-7_25-48.png
ROUTE-8 Signed-in no-membership invited user saw pending invitation card on /no-organization, and the card link included invitation ID plus URL-encoded current email. 32:13 ROUTE-8_32-13.png
LOGIC-1 Disabling the last active sign-in method was blocked with an error toast and persisted as enabled after refresh. 38:54 LOGIC-1_38-54.png
LOGIC-2 New SSO provider was created successfully and appeared as an available login option for a matching-domain account. 38:54 LOGIC-2_38-54.png
LOGIC-5 Invite dialog returned split outcomes (1 successful, 1 failed duplicate-member) and copy-link fallback remained available with successful clipboard-copy toast. 33:46 LOGIC-5_33-46.png
EDGE-1 Burst clicks converged to one stable method-picker state with no unrecoverable oscillation. 4:41 EDGE-1_4-41.png
EDGE-2 After rapid toggles, refreshed state converged to the intended configuration with Email and password enabled and no duplicate SSO records. 38:54 EDGE-2_38-54.png
EDGE-3 At 390x844, both login and accept-invitation forms rendered with visible primary controls and no horizontal overflow. 43:43 EDGE-3_43-43.png
EDGE-4 Back/forward/reload transitions remained recoverable and actionable, including return to login email step after navigation loops. 4:43 EDGE-4_4-43.png
ADV-3 Takeover attempt was rejected: mismatched-email invitation URL rendered recipient-mismatch invalid state, and verify API returned 404 not_found for the mismatched email. 19:51 ADV-3_19-51.png
ADV-4 Authenticated request for another email returned 403 Forbidden, while own-email request returned 200 with only the caller's invitation list. 44:04 ADV-4_44-04.png
ADV-5 External and encoded returnUrl payloads did not produce external redirects; tested flows remained on localhost internal routes. 19:51 ADV-5_19-51.png
❌ Failed (1)
Test Case Summary Timestamp Screenshot
LOGIC-4 SSO provider removal can leave the organization with zero enabled sign-in methods, causing login method availability to collapse. 38:54 LOGIC-4_38-54.png
Remove SSO provider cleans allowedAuthMethods and removes login option – Failed
  • Where: Settings SSO configuration (/[tenantId]/settings) and downstream login method picker.

  • Steps to reproduce: Configure an org with a single enabled SSO method, remove that SSO provider from settings, then attempt login for that org.

  • What failed: After provider removal, org auth config can be persisted with no enabled methods; login resolves to a no-method state instead of preserving at least one usable sign-in method.

  • Code analysis: The remove-provider flow directly filters out the removed SSO method and persists the result without enforcing the same minimum-one-method guard used by toggle handlers. The login screen then correctly reflects an empty method list, which becomes a user lockout condition.

  • Relevant code:

    agents-manage-ui/src/components/settings/sso-configuration.tsx (lines 113-123)

    const currentRaw = orgResult.data?.allowedAuthMethods;
    if (currentRaw) {
      const current = JSON.parse(currentRaw) as { method: string; providerId?: string }[];
      const updated = current.filter(
        (m) => !(m.method === 'sso' && m.providerId === provider.providerId)
      );
      await authClient.organization.update({
        data: { allowedAuthMethods: JSON.stringify(updated) },
        organizationId,
      });
    }

    agents-manage-ui/src/components/settings/auth-method-configuration.tsx (lines 97-101)

    if (isEmailPasswordEnabled) {
      if (isLastMethod) {
        toast.error('At least one sign-in method must remain enabled.');
        return;
      }
    }

    agents-manage-ui/src/app/login/page.tsx (lines 320-327)

    {state.org.methods.length === 0 ? (
      <div className="rounded-lg border border-dashed p-4 text-center space-y-1">
        <p className="text-sm font-medium">No sign-in methods available</p>
        <p className="text-xs text-muted-foreground">
          Your email domain doesn't match any configured sign-in methods
        </p>
      </div>
    ) : (
  • Why this is likely a bug: Provider removal can bypass the last-method safety invariant and persist an organization-level auth configuration with zero usable methods, creating avoidable login lockout.

  • Introduced by this PR: Yes – this PR modified the relevant code.

  • Timestamp: 38:54

📋 View Recording

Screen Recording

Copy link
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

(1) Total Issues | Risk: High

Delta Assessment

This is a delta-scoped re-review following the previous review (commit 946968a6c9a2) and Ito automated test results.

✅ Critical Issues FIXED in the delta commit (7ad8708):

  1. SSO auto-provisioning null reference — Now correctly uses instance.api.addMember() instead of referencing the module-level auth placeholder. The fix at line 225 captures the local instance variable properly.

  2. Build output path mismatch — The ./auth export now correctly points to ./dist/auth/auth.js (with .d.ts types), matching the unbundled tsdown output structure.

  3. Type improvementssessionAuth.ts now has proper middleware typing, and app.ts derives AuthInstance from ReturnType<typeof createAuth> instead of importing an internal type.

  4. Debug code removedconsole.log('invitationResults', ...) has been removed from invite-member-dialog.tsx.

🔴❗ Critical (1) ❗🔴

🔴 1) sso-configuration.tsx:104 SSO provider removal bypasses last-method safety guard (Ito LOGIC-4)

Issue: The RemoveSSODialog.handleRemove() function deletes SSO providers without checking if this is the last enabled auth method. The safety guard in auth-method-configuration.tsx (lines 98-100, 114-117, 131-133) only protects the toggle switches, not the "Remove" action.

Why: If an organization has only one auth method enabled (an SSO provider), removing it will leave allowedAuthMethods empty or with all methods disabled. Users will see "No sign-in methods available" on the login page and be locked out of the organization. This was confirmed by Ito automated testing (LOGIC-4 test case failed).

Fix: Add a pre-removal check that validates at least one other enabled auth method exists before proceeding with deletion. See inline comment for implementation.

Refs:

Inline Comments:

  • 🔴 Critical: sso-configuration.tsx:104 SSO provider removal bypasses last-method safety guard

🕐 Pending Recommendations (10)

Issues from the prior review that remain applicable and should still be addressed:


🚫 REQUEST CHANGES

Summary: The delta commit successfully fixes both critical issues from the previous review (SSO auto-provisioning null reference and build output path mismatch). However, one new critical issue remains: the Ito LOGIC-4 bug where SSO provider removal can bypass the last-method safety guard and leave users locked out. This must be fixed before merge.

Additionally, the pending recommendations from the prior review (rate limiting, error handling, test coverage) should be addressed to ensure production reliability.

Discarded (0)

No findings were discarded in this delta review.

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

Note: This was a small delta (6 files), so manual orchestrator review was sufficient without dispatching subagents.

}: RemoveSSODialogProps) {
const authClient = useAuthClient();

const handleRemove = async () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 CRITICAL: SSO provider removal can leave zero enabled auth methods (Ito LOGIC-4)

Issue: The handleRemove function deletes the SSO provider and filters it from allowedAuthMethods, but does NOT check if this is the last enabled auth method. This bypasses the "at least one sign-in method" guard that exists in auth-method-configuration.tsx (lines 98-100, 114-117, 131-133).

Why: If an organization has only one auth method enabled (an SSO provider) and an admin removes it, users will be locked out with "No sign-in methods available" on the login page. This was confirmed by Ito automated testing (LOGIC-4).

Fix: Add a check before removing:

Suggested change
const handleRemove = async () => {
const handleRemove = async () => {
if (!provider) return;
// Check if this is the last enabled auth method
const orgResult = await authClient.organization.getFullOrganization({
query: { organizationId },
});
const currentRaw = orgResult.data?.allowedAuthMethods;
if (currentRaw) {
try {
const current = JSON.parse(currentRaw) as { method: string; providerId?: string; enabled?: boolean }[];
const enabledMethods = current.filter((m) => {
if (m.method === 'sso' && m.providerId === provider.providerId) return false;
if (m.method === 'sso') return m.enabled !== false;
return true; // email-password and google are always "enabled" when present
});
if (enabledMethods.length === 0) {
toast.error('Cannot remove the last sign-in method. Enable another method first.');
return;
}
} catch {
// continue with removal if parsing fails
}
}
try {
await authClient.sso.deleteProvider({ providerId: provider.providerId });

Refs:

@github-actions github-actions bot deleted a comment from claude bot Mar 13, 2026
Copy link
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 Assessment

This is a delta-scoped re-review of commit 0dd7d14b7 ("update cypress login") following the previous review at commit 7ad8708ddfef.

✅ Critical Issue FIXED (Ito LOGIC-4):

The SSO provider removal flow now correctly enforces the last-method safety guard:

const hasRemainingMethod =
  updated.some((m) => m.method === 'email-password') ||
  updated.some((m) => m.method === 'google') ||
  updated.some((m) => m.method === 'sso' && m.enabled);

if (!hasRemainingMethod) {
  toast.error('At least one sign-in method must remain enabled...');
  return;
}

The fix correctly:

  1. Fetches current allowedAuthMethods before attempting deletion
  2. Filters out the provider being removed
  3. Validates at least one enabled method would remain (accounting for the different data shapes: presence-based for email-password/google, enabled flag for SSO)
  4. Shows a user-friendly error and aborts if no methods would remain
  5. Only proceeds with deletion after validation passes

Other delta changes:

  • ✅ Cypress login tests updated to support the new multi-step login flow (email → method picker → password)
  • ✅ Removed unused getAuthMethodLabel and getAuthMethodIcon functions from auth-method-configuration.tsx
  • ✅ Cleaned up unnecessary dependencies (openid-client, @better-auth/drizzle-adapter, @better-auth/infra) from package.json files

🕐 Pending Recommendations (10)

Issues from prior reviews that remain applicable (not addressed in this delta):


💡 APPROVE WITH SUGGESTIONS

Summary: The critical LOGIC-4 bug (SSO provider removal bypassing last-method safety guard) has been properly fixed. The Ito automated test failure should now pass. The fix is well-implemented, checking for remaining enabled methods before allowing deletion.

The pending recommendations from prior reviews (rate limiting, error handling, test coverage) remain applicable and should be addressed before or shortly after merge to ensure production reliability, but they are not blocking for this delta.

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

Note: Small delta (7 files, 1 commit) — manual orchestrator review was sufficient without dispatching subagents.

@itoqa
Copy link

itoqa bot commented Mar 14, 2026

Ito Test Report ❌

20 test cases ran. 19 passed, 1 failed.

✅ Most authentication, invitation, members, and security checks behaved as expected under code-first review. ❌ One login navigation defect is still reproducible and aligns with a concrete state-transition bug in the login flow implementation.

✅ Passed (19)
Test Case Summary Timestamp Screenshot
ROUTE-2 Multi-org lookup displayed organization picker and selecting each org produced the correct org-specific sign-in state without cross-org leakage. 2:30 ROUTE-2_2-30.png
ROUTE-3 Unknown email followed the direct password branch and did not show organization or authentication-method selection steps. 0:00 ROUTE-3_0-00.png
ROUTE-4 Malformed, missing, and CRLF-encoded email inputs returned 400 bad_request responses with no 500 errors or stack traces. 4:17 ROUTE-4_4-17.png
ROUTE-5 Valid invitation verify API returned allowedAuthMethods=[email-password], and invitation UI presented only the expected email-password join flow for the invited address. 12:47 ROUTE-5_12-47.png
ROUTE-6 Opening accept-invitation without the email query parameter did not permit join flow and remained in an invalid invitation state. 12:48 ROUTE-6_12-48.png
ROUTE-7 Mismatched-email and invalid-id verification requests returned generic 404 not_found responses, and UI showed only generic invalid invitation messaging without organization disclosure. 12:51 ROUTE-7_12-51.png
SCREEN-1 Members route loaded with active members and pending invitations, and settings no longer duplicated members table content. 55:12 SCREEN-1_55-12.png
SCREEN-2 Admin settings displayed Authentication Methods with email-password toggle and Add SSO Provider control. 58:25 SCREEN-2_58-25.png
SCREEN-3 Non-admin settings access showed organization context but hid Authentication Methods management controls and SSO actions. 59:08 SCREEN-3_59-08.png
LOGIC-1 Attempting to disable the sole enabled method was blocked with a safety message and did not persist after reload. 59:40 LOGIC-1_59-40.png
LOGIC-2 Removal of the only enabled SSO provider was blocked until a fallback sign-in method was enabled, after which removal succeeded. 1:00:39 LOGIC-2_1-00-39.png
LOGIC-3 Copying the callback URL in the Add SSO form did not submit the form and produced no validation side effects with empty required fields. 1:00:54 LOGIC-3_1-00-54.png
LOGIC-4 Copy invite link action was present and successful on multiple pending invitation rows, confirming consistent availability. 55:15 LOGIC-4_55-15.png
EDGE-1 Rapid repeated Continue actions converged to a single password-step state without duplicated step rendering. 0:00 EDGE-1_0-00.png
EDGE-2 Rapid repeated submission produced one successful account-join transition and one stable terminal redirect, with no duplicate terminal flows observed. 12:52 EDGE-2_12-52.png
EDGE-3 In mobile portrait, login and invitation flows rendered usable controls without clipping; in landscape, the primary invitation action remained reachable after scroll. 1:02:17 EDGE-3_1-02-17.png
ADV-1 Cross-email pending invitation query was rejected with 403 while same-user query succeeded with 200 response shape. 21:43 ADV-1_21-43.png
ADV-2 Invitation verify requests with random UUID ids returned generic 404 not_found responses without organization metadata disclosure. 4:17 ADV-2_4-17.png
ADV-3 Script-like email payload was handled safely and returned an empty organizations response without execution indicators or server errors. 4:17 ADV-3_4-17.png
❌ Failed (1)
Test Case Summary Timestamp Screenshot
ROUTE-1 Single-org flow reached method picker and password step, but Back from password returned directly to the email step instead of returning to method picker first. 0:00 ROUTE-1_0-00.png
Login email-first flow resolves to single org method picker – Failed
  • Where: Login staged flow (/login) in single-organization path.

  • Steps to reproduce: Enter a single-org email, continue to method picker, choose email-password, reach password step, then click Back.

  • What failed: Back navigation from password jumps to the email step instead of returning to the previous method-picker step.

  • Code analysis: I reviewed the login state machine in the client route and found an explicit branch that always sends password -> email, even though the single-org path explicitly transitions through method-picker first.

  • Relevant code:

    agents-manage-ui/src/app/login/page.tsx (lines 132-136)

    if (orgs.length === 0) {
      setState({ step: 'password' });
    } else if (orgs.length === 1) {
      setState({ step: 'method-picker', org: orgs[0], multiOrg: false });
    }

    agents-manage-ui/src/app/login/page.tsx (lines 174-185)

    const handleBack = () => {
      if (state.step === 'password') {
        setState({ step: 'email' });
      } else if (state.step === 'method-picker' && state.multiOrg) {
        setError(null);
        setState({ step: 'org-picker', orgs: [] });
        handleEmailContinue({ preventDefault: () => {} } as React.FormEvent);
      } else if (state.step === 'method-picker') {
        setState({ step: 'email' });
      } else if (state.step === 'org-picker') {
        setState({ step: 'email' });
      }
    };
  • Why this is likely a bug: The state transition logic contradicts the intended staged flow by hard-coding password -> email, which skips the immediate prior step (method-picker) for single-org login.

  • Introduced by this PR: Yes – this PR modified the relevant code.

📋 View Recording

Screen Recording

Copy link
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 Assessment

This is a delta-scoped re-review of commit 1bcfc1687 ("feedback") since the last review at 0dd7d14b7.

✅ Prior Review Feedback Successfully Addressed:

Prior Issue Resolution in Delta
Auth lookup rate limiting ✅ Added (20 req/min per IP with cleanup)
Org slug info disclosure organizationSlug removed from response
Unsafe JSON parsing (as cast) ✅ Now validates with Zod discriminated union
N+1 query in getAuthLookupForEmail ✅ Fixed — uses already-fetched allowedAuthMethods
Login back-nav (Ito ROUTE-1) ✅ Fixed — preserves fromOrg state in password step
Members table accessibility ✅ Added sr-only text to actions column
429 error code support ✅ Added to ErrorCode enum and mappings

💭 Consider (3) 💭

💭 1) authLookup.ts:12 Document in-memory rate limiter limitation
The rate limiter is per-instance (process-local Map). In multi-instance deployments, requests distributed across instances bypass the limit. This is acceptable as defense-in-depth but should be documented. Consider adding a comment noting this limitation and relying on infrastructure-level rate limiting (CDN/WAF) for production.

💭 2) authLookup.ts:26 IP extraction trusts proxy headers
The getClientIp function trusts x-forwarded-for unconditionally. This is standard practice when behind a properly configured proxy (Vercel, Cloudflare), but could allow rate limit bypass if exposed directly. No action needed for typical deployments.

💭 3) authLookup.ts:55 Missing Retry-After header on 429
The rate limit response doesn't include Retry-After. Low priority since the login UI handles this gracefully.

Inline Comments:

  • 🟠 Major: authLookup.ts:12 In-memory rate limiter per-instance limitation
  • 💭 Consider: authLookup.ts:26 IP extraction trusts proxy headers
  • 💭 Consider: authLookup.ts:55 Missing Retry-After header

🕐 Pending Recommendations (6)

Issues from prior reviews that remain applicable (not addressed in this delta but noted for tracking):


✅ APPROVE

Summary: The delta successfully addresses all the critical and major feedback from the prior review:

  1. Rate limiting — Added with reasonable defaults (20 req/min). The in-memory limitation is documented in inline comments and acceptable for defense-in-depth.
  2. Info disclosureorganizationSlug removed from the public response.
  3. Type safety — Zod validation replaces unsafe as cast in parseAllowedAuthMethods.
  4. Query optimization — N+1 pattern fixed.
  5. UX bug fix — Login back-navigation now correctly returns to method-picker.

The remaining pending recommendations (error handling, test coverage, docs) are tracked but not blocking for this delta. The rate limiter observations are "Consider" items for hardening rather than functional issues.

Well done addressing the feedback! 🎉

(Note: Unable to submit formal approval due to GitHub App permissions. This review recommends approval.)

Discarded (2)
Location Issue Reason Discarded
authLookup.ts:14-24 setInterval without cleanup may delay graceful shutdown Very minor — interval is lightweight (60s), callback is fast, and Node.js handles this on process exit
authLookup.ts:86-89 Logging orgCount reveals successful lookups The API response already reveals this; logging it doesn't create additional disclosure
Reviewers (4)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
pr-review-standards 0 0 0 0 0 0 0
pr-review-security-iam 3 0 2 0 1 0 0
pr-review-sre 3 0 1 0 0 0 2
pr-review-types 0 0 0 0 0 0 0
Total 6 0 3 0 1 0 2

Note: Small delta (8 files, 1 commit) focused on addressing prior review feedback. Standards and Types reviewers found no issues — the changes improved code quality.


const RATE_LIMIT_WINDOW_MS = 60_000;
const RATE_LIMIT_MAX_REQUESTS = 20;
const ipRequestTimestamps = new Map<string, number[]>();
Copy link
Contributor

Choose a reason for hiding this comment

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

🟠 MAJOR: In-memory rate limiter is per-instance only

Issue: The rate limiter uses a module-level Map<string, number[]> which is process-local. In a multi-instance deployment behind a load balancer, each instance maintains its own independent rate counter. An attacker can bypass the limit by distributing requests across instances.

Why: At 20 req/min per instance with N instances, an attacker could effectively enumerate N × 20 emails per minute. The codebase explicitly acknowledges multi-instance deployments as a supported configuration (see getInProcessFetch() pattern).

Fix: This is acceptable as defense-in-depth for single-instance or light-traffic scenarios, but consider:

  1. Document the limitation in the code comments
  2. Infrastructure-level rate limiting (CDN/WAF) for production
  3. Future: Redis-based rate limiting if enumeration becomes a real threat

For now, adding a comment acknowledging this limitation would be helpful:

// NOTE: In-memory rate limiter provides per-instance protection only.
// For multi-instance deployments, rely on infrastructure-level rate limiting.

Refs:

}
}, RATE_LIMIT_WINDOW_MS);

function getClientIp(c: { req: { header: (name: string) => string | undefined } }): string {
Copy link
Contributor

Choose a reason for hiding this comment

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

💭 Consider: IP extraction trusts proxy headers unconditionally

Issue: The x-forwarded-for header is trusted without validation. An attacker could spoof this header to bypass rate limiting:

curl -H 'x-forwarded-for: fake-ip-1' /api/auth-lookup?email=test@example.com
curl -H 'x-forwarded-for: fake-ip-2' /api/auth-lookup?email=test@example.com

Why: This is a common pattern and typically safe when deployed behind a trusted proxy that overwrites incoming x-forwarded-for headers. However, if the API is exposed directly or behind a misconfigured proxy, the rate limit can be bypassed.

Fix: This is deployment-dependent. Options:

  1. Ensure the load balancer/reverse proxy strips incoming x-forwarded-for headers (infrastructure fix)
  2. Use Hono's c.req.ip with trusted proxy configuration
  3. Add an env flag to control whether proxy headers are trusted

No action required if your deployment is behind a properly configured proxy (Vercel, Cloudflare, etc.).


if (recent.length >= RATE_LIMIT_MAX_REQUESTS) {
logger.warn({ clientIp, count: recent.length }, 'auth-lookup rate limit exceeded');
throw createApiError({
Copy link
Contributor

Choose a reason for hiding this comment

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

💭 Consider: Adding Retry-After header to 429 response

Issue: The 429 response doesn't include a Retry-After header to indicate when the client should retry.

Why: The codebase's retryWithBackoff utility in packages/agents-core/src/utils/retry.ts respects Retry-After headers. Without it, clients must guess retry timing.

Fix: Optional improvement:

// Calculate time until oldest request expires from window
const oldestTimestamp = recent[0];
const retryAfterSeconds = Math.ceil((RATE_LIMIT_WINDOW_MS - (now - oldestTimestamp)) / 1000);

// Could add to response headers if createApiError supported it
// For now, the message "try again later" is sufficient

Low priority since this endpoint is called by the login UI which handles the error gracefully.

@github-actions github-actions bot deleted a comment from claude bot Mar 16, 2026
Copy link
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 Assessment

This is a delta-scoped re-review of commit d5ade894d ("fix test") since the last approved review at 7904496.

Delta contents: 2 files, 9 lines changed — minor code quality improvements and a bugfix.

File Change Assessment
sso-configuration.tsx Replaced inline JSON parsing with parseAllowedAuthMethods() in RegisterSSOForm ✅ Code quality — uses established Zod-validated parsing utility instead of inline try/catch
init.ts Added isServiceAccount: true when creating admin user ✅ Bugfix — properly sets serviceAccountUserId on the organization record

✅ Delta Changes Verified

1. sso-configuration.tsx:242 — Good refactor

// Before (inline JSON parsing with silent catch):
let currentMethods: AllowedAuthMethod[] = [];
try {
  const raw = orgResult.data?.allowedAuthMethods;
  if (raw) currentMethods = JSON.parse(raw);
} catch {
  // default to empty
}

// After (uses validated utility):
const currentMethods = parseAllowedAuthMethods(orgResult.data?.allowedAuthMethods);

This is consistent with the prior review feedback to use Zod validation. The parseAllowedAuthMethods function (added in commit 1bcfc1687) validates the JSON structure and provides safe fallback behavior.

2. init.ts:142 — Correct fix

The isServiceAccount: true flag ensures the admin user created during pnpm db:auth:init is properly registered as the organization's service account. This sets serviceAccountUserId on the organization record, which other parts of the codebase may depend on.

📊 Ito Test Status

The most recent Ito test report (2026-03-16T16:44:37Z) showed 3 failures:

  • LOGIC-3: SSO lifecycle regression causing admin lockout
  • EDGE-4: No-org page not showing pending invitations
  • ADV-2: Script-like email accepted by auth-lookup

These failures are in different code paths than what this delta modifies:

  • LOGIC-3 involves the SSO provider edit flow, not the registration flow touched here
  • EDGE-4 involves the no-organization page authorization context
  • ADV-2 involves email validation in authLookup.ts (not modified)

These remain as known issues from prior Ito runs and should be tracked separately from this delta review.

🕐 Pending Recommendations (5)

Issues from prior reviews that remain applicable but are not blocking:


✅ APPROVE

Summary: The delta is a minimal code quality improvement commit. The sso-configuration.tsx change properly uses the Zod-validated parseAllowedAuthMethods() utility instead of inline JSON parsing, and the init.ts change correctly sets the service account flag on the admin user.

The Ito test failures from the prior test run are in different code paths and are not regressions introduced by this delta. The previous approval at 7904496 stands with these minor improvements.

This PR is ready to merge once CI passes! 🎉

(Note: Unable to submit formal approval due to GitHub App permissions. This review recommends approval.)

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

Note: Minimal delta (2 files, 1 commit, 9 lines) — direct orchestrator review without subagent dispatch.

@github-actions github-actions bot deleted a comment from claude bot Mar 16, 2026
@itoqa
Copy link

itoqa bot commented Mar 16, 2026

Ito Test Report ✅

20 test cases ran. 20 passed.

The login, settings, SSO lifecycle, members, invitation security, returnUrl hardening, and mobile coverage scenarios completed successfully under code-first review, with no confirmed production-code defects meeting the failure bar in the included results.

✅ Passed (20)
Test Case Summary Timestamp Screenshot
ROUTE-4 Missing email and invalid format returned 400 bad_request, and valid email returned 200 with organizations array. 0:00 ROUTE-4_0-00.png
ADV-1 Burst traffic triggered 429 too_many_requests, then limiter recovered and subsequent request returned 200. 0:00 ADV-1_0-00.png
ADV-2 Known and unknown probes both returned minimal organizations payloads with no organizationSlug leakage. 0:00 ADV-2_0-00.png
ROUTE-1 No-org email path transitioned to password form and invalid credentials produced controlled auth error without app crash. 2:00 ROUTE-1_2-00.png
ROUTE-2 Single-org method picker rendered for admin account and email-password sign-in redirected to internal authenticated landing page. 2:30 ROUTE-2_2-30.png
EDGE-4 Repeated rapid click/key submissions did not deadlock the UI; login transition remained coherent with expected auth responses. 4:00 EDGE-4_4-00.png
SCREEN-1 Authentication methods card rendered correctly and email-password toggle changes persisted after refresh while alternate SSO method remained enabled. 7:09 SCREEN-1_7-09.png
EDGE-1 When only one sign-in method remained enabled, disabling it was prevented and a lockout-protection toast was shown. 7:09 EDGE-1_7-09.png
EDGE-5 Rapid auth-method toggle operations remained stable and the final intended enabled state persisted after immediate refresh. 7:09 EDGE-5_7-09.png
SCREEN-2 Required-field validation and discovery-error handling were observed, then provider registration succeeded with a reachable issuer and the new provider appeared in auth methods. 9:55 SCREEN-2_9-55.png
SCREEN-3 Provider metadata update and auto-provision toggle changes were saved and remained persisted after page reload. 9:55 SCREEN-3_9-55.png
EDGE-2 When SSO was the only enabled sign-in method, removal was blocked with a lockout-prevention message and the provider remained available. 9:55 EDGE-2_9-55.png
ROUTE-5 Sidebar navigation opened /default/members, Members UI rendered, and page remained stable after refresh with pending invitations visible. 11:13 ROUTE-5_11-13.png
ROUTE-6 Dialog rejected invalid email format, then accepted valid emails and displayed per-email result rows with copy-link actions. 11:40 ROUTE-6_11-40.png
ROUTE-7 Pending invitation action menu exposed Copy invite link, copy completed successfully, and clipboard URL matched accept-invitation pattern. 13:05 ROUTE-7_13-05.png
ROUTE-8 Invitation verify returned allowedAuthMethods as an empty array, and the UI rendered the explicit no sign-in methods state without exposing extra auth options. 17:39 ROUTE-8_17-39.png
ADV-3 Known invitation ID with wrong email and random invitation ID both returned safe not_found responses, and the browser showed deterministic invalid invitation UI without metadata leakage. 23:59 ADV-3_23-59.png
ADV-4 Authenticated pending invitation lookup returned 403 for a different email and 200 for the authenticated email, and the no-organization UI did not expose cross-user invitation artifacts. 24:19 ADV-4_24-19.png
ADV-5 External returnUrl values were sanitized to internal destinations for both login and invitation acceptance, while a valid internal returnUrl (/default/members) was honored. 34:18 ADV-5_34-18.png
EDGE-6 In iPhone 12 portrait and landscape, login controls remained visible and tappable, and invitation flow content remained readable without horizontal clipping of critical UI. 28:39 EDGE-6_28-39.png
📋 View Recording

Screen Recording

@pullfrog
Copy link
Contributor

pullfrog bot commented Mar 19, 2026

TL;DR — Adds per-organization SSO provider management (register/edit/remove OIDC and SAML providers from the UI), an email-first login flow that resolves auth methods by domain, and controlled auto-provisioning that defers to pending invitations. Members management is extracted from the settings page into its own /members route.

Key changes

  • organization.allowed_auth_methods — New text column on the organization table (runtime migration 0024) stores a JSON array of enabled auth methods (email-password, google, sso) per org, parsed/validated by a Zod discriminated union in auth-types.ts.
  • /manage/api/auth-lookup — New unauthenticated, IP-rate-limited endpoint resolves an email address to matching organizations and their allowed sign-in methods (via SSO domain match and user membership lookup).
  • auth-config-utils.ts — Extracts shouldAutoProvision, getTrustedOrigins, extractCookieDomain, and hasCredentialAccount out of auth.ts into a standalone utilities module with full test coverage.
  • sso() plugin reconfigured — Disables built-in organizationProvisioning and adds a custom provisionUser hook that checks per-provider autoProvision flag and skips provisioning when a pending invitation exists.
  • trustedProviders made dynamic — Account linking now queries SSO provider IDs from the database instead of a static allow-list, so newly registered OIDC/SAML providers are trusted without a redeploy.
  • trustedOrigins made dynamic — Reads SSO provider issuers from the DB and performs OIDC discovery to collect all endpoint origins, ensuring Better Auth's CSRF protection allows IdP-initiated flows.
  • Login page rewrite — Multi-step email-first flow (emailorg-pickermethod-pickerpassword) that calls the auth-lookup API and filters available sign-in options per org.
  • SSOConfiguration component — Full CRUD UI for OIDC providers: register with issuer discovery, edit client credentials/scopes, remove with last-method guard, auto-provision toggle.
  • AuthMethodConfiguration component — Toggle switches for email-password, Google, and each SSO provider with a safety guard preventing all methods from being disabled.
  • Members page extracted — Member table, invitation management, role assignment, and project access moved from /settings to a dedicated /[tenantId]/members route.
  • ssoHelpers.ts deleted — Removes the static createOIDCProvider / createAuth0Provider helpers; SSO providers are now registered dynamically through the Better Auth SSO plugin API from the UI.
  • SpiceDB sync moved to beforeAddMember — Permission sync now runs in beforeAddMember (in addition to beforeAcceptInvitation) so auto-provisioned SSO users get SpiceDB permissions before the response returns, and failures throw instead of being silently swallowed.
  • lastLoginMethod plugin — Adds the lastLoginMethod Better Auth plugin with a custom path resolver so the login page can remember and surface the user's previous sign-in method.
  • Test coverage — 234-line shouldAutoProvision test suite (11 cases including invitation precedence) and 431-line authLookup test suite covering domain filtering, multi-org lookup, and method option conversion.

Summary | 72 files | 7 commits | base: mainfeat/org-sso-login


organization.allowed_auth_methods schema and types

Before: Auth methods were implicitly determined by server-side config; no per-org customization existed.
After: Each organization stores a JSON allowed_auth_methods text column — a discriminated union of email-password, google, and sso (with providerId, displayName, autoProvision, enabled) — parsed by parseAllowedAuthMethods with a safe fallback to [{ method: 'email-password' }].

The runtime migration adds the column; the auth-types.ts module defines the Zod schema (allowedAuthMethodSchema) and serialization helpers. The AllowedAuthMethod type is exported from @inkeep/agents-core/auth/auth-types as a new package entry point.

auth-types.ts · auth-schema.ts · 0024_moaning_kingpin.sql


Auth-lookup endpoint and email-first login flow

Before: The login page showed all sign-in options unconditionally; no server-side resolution of methods per email.
After: GET /manage/api/auth-lookup?email=... resolves email → SSO domain providers → org membership → filtered MethodOption[] per org. The login page uses a state machine (emailorg-pickermethod-pickerpassword) to guide users through available options.

The endpoint is unauthenticated with an in-memory IP rate limiter (20 req/min). It strips organizationSlug from responses to minimize info disclosure.

How does the lookup resolve orgs?
  1. Extract email domain, query sso_provider table for domain matches → collect org IDs
  2. For each matched org, read allowed_auth_methods and filter SSO entries to domain-matched providers
  3. Look up existing user by email → get org memberships → add any orgs not already found
  4. Merge results into a deduplicated OrgAuthInfo[] response

authLookup.ts · organizations.ts · login/page.tsx


shouldAutoProvision with invitation precedence

Before: SSO auto-provisioning was handled by the @better-auth/sso plugin's built-in organizationProvisioning.
After: Built-in provisioning is disabled. A custom provisionUser hook calls shouldAutoProvision() which checks: provider has an organizationId → org exists → SSO entry is enabled with autoProvision: true → user is not already a member → no pending invitation exists (preserving explicit role assignments like Admin).

The invitation-precedence check queries invitation for status = 'pending' matching email + org. This ensures an invited user gets the role specified in their invitation rather than the default member role from auto-provision.

auth-config-utils.ts · auth.ts · shouldAutoProvision.test.ts


SSO provider CRUD UI and auth method toggles

Before: SSO providers were registered statically in server config via ssoHelpers.ts; no UI management existed.
After: Admins can register OIDC providers (issuer URL, client ID/secret, domain, scopes, auto-provision toggle), edit existing providers, and remove them — all from the settings page. Auth method toggles let admins enable/disable email-password, Google, and each SSO provider with a guard preventing all methods from being disabled.

The SSOConfiguration component handles provider lifecycle via the Better Auth SSO plugin API (authClient.sso.register, authClient.sso.providers, authClient.sso.deleteProvider). The AuthMethodConfiguration component persists toggles by updating organization.allowedAuthMethods via authClient.organization.update.

sso-configuration.tsx · auth-method-configuration.tsx · settings/page.tsx


Dynamic trusted origins and trusted providers

Before: trustedOrigins was a static array of env-configured URLs; trustedProviders was a hardcoded list (['auth0', 'google', 'email-password']).
After: Both are dynamic. getTrustedOrigins() queries all SSO provider issuers from the DB, performs OIDC discovery to collect endpoint origins, and merges with static origins. For /sso/register POST requests, it also trusts the issuer from the request body before the provider is persisted. trustedProviders queries sso_provider.providerId at runtime.

Source Origins
Static localhost:3000, localhost:3002, MANAGE_UI_URL, API_URL, TRUSTED_ORIGIN
DB All sso_provider.issuer origins
OIDC discovery authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, revocation_endpoint, introspection_endpoint origins
Request body (register only) Issuer from POST body + its discovery endpoints

auth-config-utils.ts · auth.ts · auth.ts (DAL)


Members page extraction and SpiceDB sync improvements

Before: Member management (table, invitations, role assignment, project access) lived inside the settings page; SpiceDB sync ran only in afterAcceptInvitation and silently swallowed errors.
After: Members have a dedicated /[tenantId]/members route with layout, loading skeleton, and page. SpiceDB sync now runs in beforeAddMember (covering auto-provisioned SSO users) and beforeAcceptInvitation, and throws on failure instead of logging silently.

members/page.tsx · members/layout.tsx · auth.ts

Pullfrog  | View workflow run | Triggered by Pullfrogpullfrog.com𝕏

@pullfrog
Copy link
Contributor

pullfrog bot commented Mar 19, 2026

TL;DR — Adds per-organization SSO provider management (register/edit/remove OIDC and SAML providers from the UI), an email-first login flow that resolves auth methods by domain, and controlled auto-provisioning that defers to pending invitations. Members management is extracted from the settings page into its own /members route. SSO provider CRUD is gated behind the cloud deployment flag so self-hosted instances see only auth method toggles.

Key changes

  • organization.allowed_auth_methods — New text column on the organization table (runtime migration 0024) stores a JSON array of enabled auth methods (email-password, google, sso) per org, parsed/validated by a Zod discriminated union in auth-types.ts.
  • /manage/api/auth-lookup — New unauthenticated, IP-rate-limited endpoint resolves an email address to matching organizations and their allowed sign-in methods (via SSO domain match and user membership lookup).
  • auth-config-utils.ts — Extracts shouldAutoProvision, getTrustedOrigins, extractCookieDomain, and hasCredentialAccount out of auth.ts into a standalone utilities module with full test coverage.
  • sso() plugin reconfigured — Disables built-in organizationProvisioning and adds a custom provisionUser hook that checks per-provider autoProvision flag and skips provisioning when a pending invitation exists.
  • trustedProviders made dynamic — Account linking now queries SSO provider IDs from the database instead of a static allow-list, so newly registered OIDC/SAML providers are trusted without a redeploy.
  • trustedOrigins made dynamic — Reads SSO provider issuers from the DB and performs OIDC discovery to collect all endpoint origins, ensuring Better Auth's CSRF protection allows IdP-initiated flows.
  • Login page rewrite — Multi-step email-first flow (emailorg-pickermethod-pickerpassword) that calls the auth-lookup API and filters available sign-in options per org.
  • SSOConfiguration component — Full CRUD UI for OIDC providers: register with issuer discovery, edit client credentials/scopes, remove with last-method guard, auto-provision toggle. Gated behind PUBLIC_IS_INKEEP_CLOUD_DEPLOYMENT so self-hosted deployments skip SSO provider listing and CRUD entirely.
  • AuthMethodConfiguration component — Toggle switches for email-password, Google, and each SSO provider with a safety guard preventing all methods from being disabled.
  • Members page extracted — Member table, invitation management, role assignment, and project access moved from /settings to a dedicated /[tenantId]/members route.
  • ssoHelpers.ts deleted — Removes the static createOIDCProvider / createAuth0Provider helpers; SSO providers are now registered dynamically through the Better Auth SSO plugin API from the UI.
  • SpiceDB sync moved to beforeAddMember — Permission sync now runs in beforeAddMember (in addition to beforeAcceptInvitation) so auto-provisioned SSO users get SpiceDB permissions before the response returns, and failures throw instead of being silently swallowed.
  • lastLoginMethod plugin — Adds the lastLoginMethod Better Auth plugin with a custom path resolver so the login page can remember and surface the user's previous sign-in method.
  • Test coverage — 234-line shouldAutoProvision test suite (11 cases including invitation precedence) and 431-line authLookup test suite covering domain filtering, multi-org lookup, and method option conversion.

Summary | 72 files | 8 commits | base: mainfeat/org-sso-login


organization.allowed_auth_methods schema and types

Before: Auth methods were implicitly determined by server-side config; no per-org customization existed.
After: Each organization stores a JSON allowed_auth_methods text column — a discriminated union of email-password, google, and sso (with providerId, displayName, autoProvision, enabled) — parsed by parseAllowedAuthMethods with a safe fallback to [{ method: 'email-password' }].

The runtime migration adds the column; the auth-types.ts module defines the Zod schema (allowedAuthMethodSchema) and serialization helpers. The AllowedAuthMethod type is exported from @inkeep/agents-core/auth/auth-types as a new package entry point.

auth-types.ts · auth-schema.ts · 0024_moaning_kingpin.sql


Auth-lookup endpoint and email-first login flow

Before: The login page showed all sign-in options unconditionally; no server-side resolution of methods per email.
After: GET /manage/api/auth-lookup?email=... resolves email → SSO domain providers → org membership → filtered MethodOption[] per org. The login page uses a state machine (emailorg-pickermethod-pickerpassword) to guide users through available options.

The endpoint is unauthenticated with an in-memory IP rate limiter (20 req/min). It strips organizationSlug from responses to minimize info disclosure.

How does the lookup resolve orgs?
  1. Extract email domain, query sso_provider table for domain matches → collect org IDs
  2. For each matched org, read allowed_auth_methods and filter SSO entries to domain-matched providers
  3. Look up existing user by email → get org memberships → add any orgs not already found
  4. Merge results into a deduplicated OrgAuthInfo[] response

authLookup.ts · organizations.ts · login/page.tsx


shouldAutoProvision with invitation precedence

Before: SSO auto-provisioning was handled by the @better-auth/sso plugin's built-in organizationProvisioning.
After: Built-in provisioning is disabled. A custom provisionUser hook calls shouldAutoProvision() which checks: provider has an organizationId → org exists → SSO entry is enabled with autoProvision: true → user is not already a member → no pending invitation exists (preserving explicit role assignments like Admin).

The invitation-precedence check queries invitation for status = 'pending' matching email + org. This ensures an invited user gets the role specified in their invitation rather than the default member role from auto-provision.

auth-config-utils.ts · auth.ts · shouldAutoProvision.test.ts


SSO provider CRUD UI gated behind cloud deployment flag

Before: SSOConfiguration and useSSOProviders ran unconditionally for all deployments.
After: The settings page checks PUBLIC_IS_INKEEP_CLOUD_DEPLOYMENT; self-hosted instances skip SSO provider fetching, hide the register/edit/remove forms, and pass an empty provider list to AuthMethodConfiguration. useSSOProviders accepts undefined as organizationId and short-circuits.

This ensures SSO provider lifecycle management is only available on Inkeep-hosted deployments where the Better Auth SSO plugin API is fully configured. Auth method toggles (email-password, Google) remain available for all deployments.

settings/page.tsx · sso-configuration.tsx


Dynamic trusted origins and trusted providers

Before: trustedOrigins was a static array of env-configured URLs; trustedProviders was a hardcoded list (['auth0', 'google', 'email-password']).
After: Both are dynamic. getTrustedOrigins() queries all SSO provider issuers from the DB, performs OIDC discovery to collect endpoint origins, and merges with static origins. For /sso/register POST requests, it also trusts the issuer from the request body before the provider is persisted. trustedProviders queries sso_provider.providerId at runtime.

Source Origins
Static localhost:3000, localhost:3002, MANAGE_UI_URL, API_URL, TRUSTED_ORIGIN
DB All sso_provider.issuer origins
OIDC discovery authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, revocation_endpoint, introspection_endpoint origins
Request body (register only) Issuer from POST body + its discovery endpoints

auth-config-utils.ts · auth.ts · auth.ts (DAL)


Members page extraction and SpiceDB sync improvements

Before: Member management (table, invitations, role assignment, project access) lived inside the settings page; SpiceDB sync ran only in afterAcceptInvitation and silently swallowed errors.
After: Members have a dedicated /[tenantId]/members route with layout, loading skeleton, and page. SpiceDB sync now runs in beforeAddMember (covering auto-provisioned SSO users) and beforeAcceptInvitation, and throws on failure instead of logging silently.

members/page.tsx · members/layout.tsx · auth.ts

Pullfrog  | View workflow run | Triggered by Pullfrogpullfrog.com𝕏

@github-actions github-actions bot deleted a comment from claude bot Mar 19, 2026
@claude
Copy link
Contributor

claude bot commented Mar 19, 2026

Claude encountered an error —— View job


PR Review Progress

  • Phase 1: Analyze PR context and prior feedback
  • Phase 2: Assess delta scope
  • Phase 3: Dispatch reviewers (if needed)
  • Phase 4: Synthesize findings
  • Phase 5: Add inline comments to pending review
  • Phase 6: Submit review with summary

Phase 1 Complete: This is a delta review scoped to changes since the last automated review at commit d5ade894d9f7.

Key Observations:

  • Last review: commit d5ade894d (2026-03-16T18:01:30Z) — APPROVED with 0 issues
  • 47 commits since last review, 267 files changed in delta
  • Most delta changes are merge commits from origin/main (unrelated to SSO feature)
  • Ito test results: 20/20 passed (most recent at 2026-03-16T19:05:36Z) ✅
  • All prior critical issues (SSO null ref, build path, LOGIC-4) were fixed

Analyzing delta to verify SSO files are unchanged...

@vercel vercel bot temporarily deployed to Preview – agents-docs March 19, 2026 14:59 Inactive
@omar-inkeep omar-inkeep added this pull request to the merge queue Mar 19, 2026
Merged via the queue into main with commit b1e6ced Mar 19, 2026
14 of 15 checks passed
@omar-inkeep omar-inkeep deleted the feat/org-sso-login branch March 19, 2026 15:55
@itoqa
Copy link

itoqa bot commented Mar 19, 2026

Ito Test Report ❌

15 test cases ran. 14 passed, 1 failed.

✅ Most authentication, routing, invitation validation, and settings scenarios executed successfully in this run. 🔍 Verification found one likely product bug in the no-organization invitation entry path where pending invitations can be suppressed in the UI despite valid invitation data.

✅ Passed (14)
Test Case Summary Timestamp Screenshot
ROUTE-1 Valid email advanced from email entry to org-aware method picker without errors. 0:00 ROUTE-1_0-00.png
ROUTE-2 Executed with browser-level auth-lookup interception after local seeding attempts failed; org picker rendered two organizations, selection to method picker worked, and back navigation returned to org picker/email as expected. 13:09 ROUTE-2_13-09.png
ROUTE-3 A forced 429 on auth-lookup still led to password entry with the selected email preserved. 0:00 ROUTE-3_0-00.png
ROUTE-4 Authenticated admin reached /default/members, verified members/invitations UI, and successfully executed Copy invite link with success toast. 15:25 ROUTE-4_15-25.png
ROUTE-5 Attempting to disable the only enabled method displayed the minimum-one guard toast, and after reload the method remained enabled. 20:49 ROUTE-5_20-49.png
ROUTE-6 Added a new SSO provider, edited display name and auto-provision state successfully, and removal was correctly blocked by minimum enabled-method protection. 35:02 ROUTE-6_35-02.png
ROUTE-7 Valid invitation verification succeeded and the UI enforced method policy by showing explicit no-method guidance for this email domain with no alternate auth method options. 41:04 ROUTE-7_41-04.png
ROUTE-9 The root returnUrl case performed a safe internal redirect and stabilized without redirect looping. 0:00 ROUTE-9_0-00.png
EDGE-1 Malformed emails were safely rejected without breaking the page, and corrected input resumed the flow. 0:00 EDGE-1_0-00.png
EDGE-2 Auth lookup for user@example.com produced an org with zero available methods, login UI showed explicit empty-state guidance, and Back returned to the email step without dead-end state. 37:04 EDGE-2_37-04.png
EDGE-3 When the email query parameter was omitted, API returned 400 and UI rendered the invalid-link guard state without actionable acceptance controls. 41:04 EDGE-3_41-04.png
ADV-1 External returnUrl payload did not redirect off-site and resolved to a safe internal destination. 0:00 ADV-1_0-00.png
ADV-5 Rapid repeated Continue clicks were safely deduplicated to a single auth-lookup request and the UI transitioned to a stable guarded state without duplicate side effects. 41:04 ADV-5_41-04.png
ADV-6 At iPhone-like portrait/landscape rotations, login and invalid invitation journeys remained usable with visible interactive controls and consistent back/forward behavior. 13:09 ADV-6_13-09.png
❌ Failed (1)
Test Case Summary Timestamp Screenshot
ROUTE-8 For pendinguser@example.com, /no-organization rendered no invitation cards (expected actionable invitation links). Direct accept-invitation URL was reachable, but required card-driven entry point was not present. 17:41 ROUTE-8_17-41.png
No-organization page surfaces pending invitations and deep links – Failed
  • Where: /no-organization invitation card list for signed-in users with no org membership.

  • Steps to reproduce: Sign in as a user with pending invitations and no organization membership, navigate to /no-organization, and observe whether invitation cards are rendered.

  • What failed: The page rendered no actionable invitation cards even though invitation verification data exists for the same user and invitation context.

  • Code analysis: The UI depends on getPendingInvitations and treats any request error as an empty array; the API route itself requires authenticated same-email access, so transient auth/cookie/request failures are converted into a silent "no invitations" state.

  • Relevant code:

    agents-manage-ui/src/lib/actions/invitations.ts (lines 19-26)

    export async function getPendingInvitations(email: string): Promise<PendingInvitation[]> {
      try {
        return await makeManagementApiRequest<PendingInvitation[]>(
          `api/invitations/pending?email=${encodeURIComponent(email)}`
        );
      } catch {
        return [];
      }
    }

    agents-manage-ui/src/app/no-organization/page.tsx (lines 33-45)

        try {
          const invitations = await getPendingInvitations(user.email);
          setPendingInvites(
            invitations.map((inv) => ({
              id: inv.id,
              organizationName: inv.organizationName,
              role: inv.role,
            }))
          );
        } catch {
          // Silently fail -- still show the no-org page
        } finally {
          setLoadingInvites(false);
        }

    agents-api/src/domains/manage/routes/invitations.ts (lines 157-174)

    invitationsRoutes.get('/pending', async (c) => {
      const email = c.req.query('email');
      const authenticatedEmail = c.get('userEmail');
    
      if (!email) {
        throw createApiError({ code: 'bad_request', message: 'Email parameter is required' });
      }
    
      // Only allow querying own invitations
      if (email !== authenticatedEmail) {
        throw createApiError({
          code: 'forbidden',
          message: 'Cannot access invitations for another email',
        });
      }
  • Why this is likely a bug: The no-org invitation entry point can misreport existing invitations as none because errors are swallowed and collapsed into empty data, producing incorrect user-visible behavior.

  • Introduced by this PR: Yes – this PR modified the relevant code.

  • Timestamp: 17:41

📋 View Recording

Screen Recording

Copy link

@imanilie imanilie left a comment

Choose a reason for hiding this comment

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

Hallo guten Abend bin ich ganz neu in diese abgekommen und sie haben mir Nachrichten gelassen. Ich musste mich melden bei Ihnen und ich hab mich gemeldet

@inkeep inkeep deleted a comment from imanilie Mar 20, 2026
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.

2 participants