Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ GOOGLE_CLIENT_SECRET=""
# MICROSOFT_CLIENT_SECRET=""

# Optional. Enables Slack account linking on Settings > Connections.
# Add APP_URL + /api/auth/oauth2/callback/slack as the Slack OAuth redirect URL.
# Add API_URL + /api/auth/callback/slack as the Slack OAuth redirect URL.
# SLACK_CLIENT_ID=""
# SLACK_CLIENT_SECRET=""

Expand Down
1 change: 1 addition & 0 deletions apps/agent/test/slack-membership.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ async function connect() {
where: { id: ACCOUNT_ID },
create: {
id: ACCOUNT_ID,
issuer: "local:oauth:slack",
accountId: "T-JOIN-SPEC",
providerId: "slack",
userId: USER_ID,
Expand Down
1 change: 1 addition & 0 deletions apps/agent/test/slack-people.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ async function connect() {
where: { id: ACCOUNT_ID },
create: {
id: ACCOUNT_ID,
issuer: "local:oauth:slack",
accountId: "T-SPEC",
providerId: "slack",
userId: USER_ID,
Expand Down
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"@thallesp/nestjs-better-auth": "^2.7.0",
"@trpc/server": "^11.18.0",
"@vercel/blob": "^2.6.1",
"better-auth": "^1.6.25",
"better-auth": "1.7.2",
"cache-manager": "^7.2.9",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
Expand Down
23 changes: 12 additions & 11 deletions apps/api/src/mailbox/mailbox-token.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,6 @@ export class MailboxTokenService {
return parseScopes(account?.scope);
}

async isConnected(userId: string, source: SyncSource): Promise<boolean> {
const scopes = await this.grantedScopes(
userId,
PROVIDER_FOR_SOURCE[source],
);
return scopes.has(SCOPE_FOR_SOURCE[source]);
}

async signInAccounts(userId: string): Promise<SignInAccount[]> {
return this.db.account.findMany({
where: { userId },
Expand All @@ -72,8 +64,17 @@ export class MailboxTokenService {
source: SyncSource,
): Promise<TokenResult> {
const providerId = PROVIDER_FOR_SOURCE[source];

if (!(await this.isConnected(userId, source))) {
const account = await this.db.account.findFirst({
where: { userId, providerId },
select: { id: true, scope: true },
});
if (!account) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
return {
outcome: "needs-reconnect",
reason: `${label(providerId)} has no connected account.`,
};
}
if (!parseScopes(account.scope).has(SCOPE_FOR_SOURCE[source])) {
return {
outcome: "not-connected",
reason: `The ${source} scope has not been granted.`,
Expand All @@ -82,7 +83,7 @@ export class MailboxTokenService {

try {
const { accessToken } = await auth.api.getAccessToken({
body: { providerId, userId },
body: { accountId: account.id, userId },
});

if (!accessToken) {
Expand Down
2 changes: 2 additions & 0 deletions apps/api/test/mailbox-purge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ describe("disconnecting Microsoft", () => {
await db.account.create({
data: {
id: `ms-${suffix}`,
issuer: `https://login.microsoftonline.com/${suffix}/v2.0`,
accountId: `ms-account-${suffix}`,
providerId: MICROSOFT_PROVIDER_ID,
userId: outlookRep,
Expand Down Expand Up @@ -349,6 +350,7 @@ describe("disconnecting Google", () => {
await db.account.create({
data: {
id: `goog-${suffix}`,
issuer: "https://accounts.google.com",
accountId: `goog-account-${suffix}`,
providerId: GOOGLE_PROVIDER_ID,
userId: gmailRep,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ const CONNECT_ERRORS = new Map([

async function startSlackOAuth(slug: string) {
try {
const { error } = await authClient.oauth2.link({
providerId: "slack",
const { error } = await authClient.linkSocial({
provider: "slack",
callbackURL: `${window.location.origin}/${slug}/settings/connections/slack/people`,
errorCallbackURL: `${window.location.origin}/${slug}/settings/connections/slack?provider=slack`,
});
Expand Down
2 changes: 1 addition & 1 deletion apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"@trpc/server": "^11.18.0",
"@trpc/tanstack-react-query": "^11.18.0",
"api": "workspace:*",
"better-auth": "^1.6.25",
"better-auth": "1.7.2",
"eve": "^0.29.4",
"next": "16.3.0",
"next-themes": "^0.4.6",
Expand Down
170 changes: 52 additions & 118 deletions bun.lock

Large diffs are not rendered by default.

19 changes: 12 additions & 7 deletions docs/connections.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,22 @@ undo it.
Connecting is the same decision as disconnecting, because `replaceSlackConnection`
deletes every other Slack account row: a second person connecting *replaces* the
workspace's Slack, and every deployed agent then reads from and posts to whichever
Slack they installed. Hiding the button is not enough`authClient.oauth2.link`
is one POST.
Slack they installed. Hiding the button is not enough. `authClient.linkSocial`
sends one POST.

`slackConnectGuard` (`packages/auth/src/slack-connect.ts`) is Better Auth's
`hooks.before`, and it asks the same `canManageConnections` the API does. It
covers all three doors: `/oauth2/link`, `/sign-in/oauth2` (Slack is a connection,
never a sign-in method, and that endpoint needs no session) and
`/oauth2/callback/slack`. The callback is the one that matters — refusing there
happens **before the code is exchanged**, so a refused attempt writes no
guards Slack account-linking starts through `/link-social`. Public Slack sign-in
starts through `/sign-in/social` remain available to Better Auth. The guard also
reads the server-generated OAuth state before `/callback/slack`. It guards the
callback only when that state identifies an account-linking transaction. A
normal Slack sign-in callback remains available to Better Auth. The callback
check happens before Better Auth exchanges the code. A refused link writes no
`SlackWorkspaceGrant` user token and deletes no bot token. Google and Microsoft
sign in on different paths and never reach the guard.
callbacks never reach the Slack guard.

Existing Slack applications must replace `/api/auth/oauth2/callback/slack` with
`/api/auth/callback/slack` before this upgrade reaches production.

A workspace with no owner and no admin lets any member connect. There is nobody
left to ask, and a fresh install must not be locked out of its first connection.
Expand Down
11 changes: 11 additions & 0 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,17 @@ builds, and the pages that touch them fail. Test schema changes locally, where
worse: every preview applied its own migrations to the production database, so on
2026-08-07 the live schema ran six migrations ahead of the live code all day.

### Better Auth 1.7 account identities

Migration `20260830221000_better_auth_account_identity` adds issuer-scoped account identities.
Back up the `account` and `user` tables before production deployment.
The migration preserves provider-scoped identities and checks for collisions.
Microsoft changes its account subject from `sub` to `oid` in Better Auth 1.7.
The migration reads `oid` from each stored Microsoft ID token.
The migration stops when a Microsoft row lacks that trusted mapping.
Repair that row from a verified Entra export before retrying the deployment.
Do not infer the mapping from email addresses.

### `migrate deploy` is not proof the schema is right

The build follows the deploy with `prisma migrate diff --exit-code` against
Expand Down
10 changes: 5 additions & 5 deletions packages/auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,19 @@
"./workspace": "./src/workspace.ts"
},
"scripts": {
"auth:generate": "better-auth generate --config src/auth.ts --output ../db/prisma/schema.prisma --y",
"auth:generate": "auth generate --config src/auth.ts --output ../db/prisma/schema.prisma --y",
"check-types": "tsc --noEmit",
"lint": "biome check .",
"test": "bun test",
"clean": "rm -rf .turbo node_modules"
},
"dependencies": {
"@better-auth/api-key": "1.6.25",
"@better-auth/sso": "1.6.25",
"@better-auth/api-key": "1.7.2",
"@better-auth/sso": "1.7.2",
"@crm/db": "workspace:*",
"@crm/env": "workspace:*",
"@crm/validation": "workspace:*",
"better-auth": "^1.6.25",
"better-auth": "1.7.2",
"zod": "^4.4.3"
},
"peerDependencies": {
Expand All @@ -35,10 +35,10 @@
}
},
"devDependencies": {
"@better-auth/cli": "^1.4.22",
"@crm/typescript-config": "workspace:*",
"@types/node": "^24.10.1",
"@types/react": "^19.2.18",
"auth": "1.7.2",
"react": "^19.2.8",
"typescript": "5.9.2"
}
Expand Down
2 changes: 1 addition & 1 deletion packages/auth/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
const socialProviders: NonNullable<BetterAuthOptions["socialProviders"]> = {};
const slackOAuth = env.slack;
const slackRedirectUri = new URL(
"/api/auth/oauth2/callback/slack",
"/api/auth/callback/slack",
env.apiUrl,
).toString();

Expand Down
3 changes: 1 addition & 2 deletions packages/auth/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { apiKeyClient } from "@better-auth/api-key/client";
import { ssoClient } from "@better-auth/sso/client";
import { genericOAuthClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient({
baseURL: globalThis.window?.location.origin,
plugins: [ssoClient(), genericOAuthClient(), apiKeyClient()],
plugins: [ssoClient(), apiKeyClient()],
});

export const { getSession, signIn, signOut, useSession } = authClient;
Expand Down
53 changes: 42 additions & 11 deletions packages/auth/src/slack-connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,25 @@ const CONNECT_MANAGER_ROLES = WORKSPACE_ROLES.filter((role) =>
canManageConnections(role),
);

const SLACK_CONNECT_START_PATHS = ["/oauth2/link", "/sign-in/oauth2"];
const OAUTH_CALLBACK_PATH = "/oauth2/callback";
const SLACK_CONNECT_START_PATH = "/link-social";
const OAUTH_CALLBACK_PATH = "/callback";
Comment thread
romanbsd marked this conversation as resolved.
Comment thread
romanbsd marked this conversation as resolved.

const connectStartBody = z.object({ providerId: z.string() });
const callbackParams = z.object({ providerId: z.string() });
const connectStartBody = z.object({ provider: z.string() });
const callbackParams = z.object({ id: z.string() });
const callbackQuery = z.object({ state: z.string() });
const oauthState = z.object({
link: z
.object({
email: z.string(),
userId: z.string(),
})
.optional(),
});

export const slackConnectGuard = createAuthMiddleware(async (ctx) => {
const guarded =
startsSlackConnect(ctx.path, ctx.body) ||
completesSlackConnect(ctx.path, ctx.params);
(await completesSlackConnect(ctx.path, ctx.params, ctx.query));
if (!guarded) return;

const session = await getSessionFromCtx(ctx, { disableCookieCache: true });
Expand Down Expand Up @@ -62,14 +71,36 @@ export const slackConnectGuard = createAuthMiddleware(async (ctx) => {
}
});

function startsSlackConnect(path: string, body: JsonValue): boolean {
if (!SLACK_CONNECT_START_PATHS.includes(path)) return false;
function startsSlackConnect(
path: string,
body: JsonValue | undefined,
): boolean {
if (path !== SLACK_CONNECT_START_PATH) return false;
const parsed = connectStartBody.safeParse(body);
return parsed.success && parsed.data.providerId === SLACK_PROVIDER_ID;
return parsed.success && parsed.data.provider === SLACK_PROVIDER_ID;
}

function completesSlackConnect(path: string, params: JsonValue): boolean {
async function completesSlackConnect(
path: string,
params: JsonValue | undefined,
query: JsonValue | undefined,
): Promise<boolean> {
if (!path.startsWith(OAUTH_CALLBACK_PATH)) return false;
const parsed = callbackParams.safeParse(params);
return parsed.success && parsed.data.providerId === SLACK_PROVIDER_ID;
const parsedParams = callbackParams.safeParse(params);
if (!parsedParams.success || parsedParams.data.id !== SLACK_PROVIDER_ID) {
return false;
}
const parsedQuery = callbackQuery.safeParse(query);
if (!parsedQuery.success) return false;
const verification = await db.verification.findFirst({
where: { identifier: parsedQuery.data.state },
select: { value: true },
});
if (!verification) return false;
try {
const parsedState = oauthState.safeParse(JSON.parse(verification.value));
return parsedState.success && parsedState.data.link !== undefined;
} catch {
return false;
}
}
Loading