Files
openwork/ee/apps/den-api/src/auth.ts
Source Open 7e82cb7253 fix(den): refresh desktop sign-in state and support local handoff (#1389)
* fix(den): make desktop signin state update immediately

* fix(den): support local auth verification and handoff

---------

Co-authored-by: src-opn <src-opn@users.noreply.github.com>
2026-04-07 16:28:52 -07:00

243 lines
6.5 KiB
TypeScript

import { db } from "./db.js";
import { env } from "./env.js";
import {
sendDenOrganizationInvitationEmail,
sendDenVerificationEmail,
} from "./email.js";
import { syncDenSignupContact } from "./loops.js";
import {
DEN_API_KEY_DEFAULT_PREFIX,
DEN_API_KEY_RATE_LIMIT_MAX,
DEN_API_KEY_RATE_LIMIT_TIME_WINDOW_MS,
} from "./api-keys.js";
import {
denOrganizationAccess,
denOrganizationStaticRoles,
} from "./organization-access.js";
import { seedDefaultOrganizationRoles } from "./orgs.js";
import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid";
import * as schema from "@openwork-ee/den-db/schema";
import { apiKey } from "@better-auth/api-key";
import { APIError } from "better-call";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { emailOTP, organization } from "better-auth/plugins";
const socialProviders = {
...(env.github.clientId && env.github.clientSecret
? {
github: {
clientId: env.github.clientId,
clientSecret: env.github.clientSecret,
},
}
: {}),
...(env.google.clientId && env.google.clientSecret
? {
google: {
clientId: env.google.clientId,
clientSecret: env.google.clientSecret,
},
}
: {}),
};
function hasRole(roleValue: string, roleName: string) {
return roleValue
.split(",")
.map((entry) => entry.trim())
.filter(Boolean)
.includes(roleName);
}
function getInvitationOrigin() {
return (
env.betterAuthTrustedOrigins.find((origin) => origin !== "*") ??
env.betterAuthUrl
);
}
function buildInvitationLink(invitationId: string) {
return new URL(
`/join-org?invite=${encodeURIComponent(invitationId)}`,
getInvitationOrigin(),
).toString();
}
export const auth = betterAuth({
baseURL: env.betterAuthUrl,
secret: env.betterAuthSecret,
trustedOrigins:
env.betterAuthTrustedOrigins.length > 0
? env.betterAuthTrustedOrigins
: undefined,
socialProviders:
Object.keys(socialProviders).length > 0 ? socialProviders : undefined,
database: drizzleAdapter(db, {
provider: "mysql",
schema,
}),
advanced: {
ipAddress: {
ipAddressHeaders: ["x-forwarded-for", "x-real-ip", "cf-connecting-ip"],
ipv6Subnet: 64,
},
database: {
generateId: (options) => {
switch (options.model) {
case "user":
return createDenTypeId("user");
case "session":
return createDenTypeId("session");
case "account":
return createDenTypeId("account");
case "verification":
return createDenTypeId("verification");
case "apikey":
case "apiKey":
return createDenTypeId("apiKey");
case "rateLimit":
return createDenTypeId("rateLimit");
case "organization":
return createDenTypeId("organization");
case "member":
return createDenTypeId("member");
case "invitation":
return createDenTypeId("invitation");
case "team":
return createDenTypeId("team");
case "teamMember":
return createDenTypeId("teamMember");
case "organizationRole":
return createDenTypeId("organizationRole");
default:
return false;
}
},
},
},
rateLimit: {
enabled: true,
storage: "database",
window: 60,
max: 20,
customRules: {
"/sign-in/email": {
window: 300,
max: 5,
},
"/sign-up/email": {
window: 3600,
max: 3,
},
"/email-otp/send-verification-otp": {
window: 3600,
max: 5,
},
"/email-otp/verify-email": {
window: 300,
max: 10,
},
"/request-password-reset": {
window: 3600,
max: 5,
},
},
},
emailVerification: {
sendOnSignUp: true,
sendOnSignIn: true,
afterEmailVerification: async (user) => {
await syncDenSignupContact({
email: user.email,
name: user.name,
});
},
},
emailAndPassword: {
enabled: true,
autoSignIn: false,
requireEmailVerification: true,
},
plugins: [
emailOTP({
overrideDefaultEmailVerification: true,
otpLength: 6,
expiresIn: 600,
allowedAttempts: 5,
async sendVerificationOTP({ email, otp, type }) {
await sendDenVerificationEmail({
email,
verificationCode: otp,
});
},
}),
organization({
ac: denOrganizationAccess,
roles: denOrganizationStaticRoles,
creatorRole: "owner",
requireEmailVerificationOnInvitation: true,
dynamicAccessControl: {
enabled: true,
},
teams: {
enabled: true,
defaultTeam: {
enabled: false,
},
},
async sendInvitationEmail(data) {
await sendDenOrganizationInvitationEmail({
email: data.email,
inviteLink: buildInvitationLink(data.id),
invitedByName: data.inviter.user.name ?? data.inviter.user.email,
invitedByEmail: data.inviter.user.email,
organizationName: data.organization.name,
role: data.role,
});
},
organizationHooks: {
afterCreateOrganization: async ({ organization }) => {
await seedDefaultOrganizationRoles(
normalizeDenTypeId("organization", organization.id),
);
},
beforeRemoveMember: async ({ member }) => {
if (hasRole(member.role, "owner")) {
throw new APIError("BAD_REQUEST", {
message: "The organization owner cannot be removed.",
});
}
},
beforeUpdateMemberRole: async ({ member, newRole }) => {
if (hasRole(member.role, "owner")) {
throw new APIError("BAD_REQUEST", {
message: "The organization owner role cannot be changed.",
});
}
if (hasRole(newRole, "owner")) {
throw new APIError("BAD_REQUEST", {
message:
"Owner can only be assigned during organization creation.",
});
}
},
},
}),
apiKey({
defaultPrefix: DEN_API_KEY_DEFAULT_PREFIX,
enableMetadata: true,
enableSessionForAPIKeys: true,
maximumNameLength: 64,
requireName: true,
storage: "database",
rateLimit: {
enabled: true,
maxRequests: DEN_API_KEY_RATE_LIMIT_MAX,
timeWindow: DEN_API_KEY_RATE_LIMIT_TIME_WINDOW_MS,
},
}),
],
});