Authentifizierung
Owlat nutzt BetterAuth mit dem Convex-Adapter für Authentifizierung und die Verwaltung von Organisationen (Teams).
Owlat nutzt BetterAuth mit dem Convex-Adapter für Authentifizierung und die Verwaltung von Organisationen (Teams).
Überblick
- BetterAuth übernimmt Registrierung, Login und Sessions der Nutzer
- Organization Plugin verwaltet die Team-Mitgliedschaft innerhalb der einen Organisation
- Session-basierter Kontext löst Nutzer, Rolle und aktive Org aus dem Request auf — es wird kein
teamId-Parameter herumgereicht
Eine Organisation pro Instanz. Jede Owlat-Installation beherbergt genau eine Organisation. Die Org wird zur Installationszeit von owlat quickstart gebootstrappt (siehe Setup-CLI & Installer), das den ersten Admin über die Backend-Action POST /seed/admin (apps/api/convex/seedAdmin.ts) erzeugt. Der BetterAuth-Endpoint auth/organization/create ist auf Plugin-Ebene deaktiviert (allowUserToCreateOrganization: false), der Auth-Client re-exportiert organization.create/organization.delete nicht, und eine Laufzeit-Invariante in getBetterAuthSessionWithRole weist jeden Request zurück, dessen Session auf eine Nicht-Singleton-Org zeigt. Mandantenfähiges SaaS-Hosting übernimmt eine separate Control Plane (eine private Codebase), die pro Mandant eine dedizierte Owlat-Instanz bereitstellt.
Architektur
useAuth()useOrganization()Konfiguration
Server (auth.ts)
apps/api/convex/auth/auth.ts baut die BetterAuth-Instanz aus einem Convex-
ActionCtx auf. Die reale Konfiguration verdrahtet vier Plugins und eigene
Rollen und stellt sämtliche Auth-E-Mails über den konfigurierten
System-E-Mail-Transport zu (internal.systemMail.sendSystemEmail), der zu dem
Zustellanbieter routet, der über EMAIL_PROVIDER (oder eine Provider-Route pro
Org) gesetzt ist — der eingebaute MTA, Amazon SES, Resend, ein generisches
SMTP-Relay oder Mailchimp Transactional. Es gibt keinen impliziten Default: Ist
keiner davon konfiguriert, wird Auth-Mail abgelehnt statt über den MTA
verschickt.
import { betterAuth } from 'better-auth';
import { organization, oneTimeToken } from 'better-auth/plugins';
import { createAccessControl } from 'better-auth/plugins/access';
import { convex, crossDomain } from '@convex-dev/better-auth/plugins';
import { getOptional } from '../lib/env';
// Custom access control: rename BetterAuth's default 'member' role to 'editor'.
const ac = createAccessControl({ ...defaultStatements });
const owner = ac.newRole({ ...ownerAc.statements });
const admin = ac.newRole({ ...adminAc.statements });
const editor = ac.newRole({ ...memberAc.statements });
export const createAuthOptions = (ctx: ActionCtx) => ({
database: authComponent.adapter(ctx),
secret: getOptional('BETTER_AUTH_SECRET'),
baseURL: getOptional('SITE_URL'),
emailAndPassword: {
enabled: true,
minPasswordLength: 10,
maxPasswordLength: 128,
sendResetPassword: async ({ user, token }) => {
await sendViaMta({ to: user.email, /* …reset email… */ });
},
},
session: {
expiresIn: 60 * 60 * 24 * 3, // 3-day sessions
updateAge: 60 * 60 * 12,
cookieCache: { enabled: true, maxAge: 5 * 60 },
},
plugins: [
// Cookieless cross-origin auth for the Tauri desktop app.
crossDomain({ siteUrl: getOptional('SITE_URL') }),
// Short-lived token redeemed by the desktop deep-link handshake.
oneTimeToken(),
// Provides /convex/token + /convex/jwks; mints the JWT Convex verifies.
// The JWT payload carries `activeOrganizationId` so the backend can
// resolve the active org from the claim without a session-table read.
convex({ authConfig, jwt: { definePayload: ({ user, session }) => ({
...user,
activeOrganizationId: session.activeOrganizationId ?? null,
}) } }),
organization({
ac,
roles: { owner, admin, editor },
// Single-org-per-instance: the one org is bootstrapped by /seed/admin;
// users cannot create more.
allowUserToCreateOrganization: false,
creatorRole: 'owner',
membershipLimit: 50,
invitationExpiresIn: 60 * 60 * 24 * 7, // 7 days
// Invitation emails are sent through the instance MTA.
sendInvitationEmail: async ({ email, organization: org, inviter, invitation }) => {
const acceptUrl = `${getOptional('SITE_URL')}/invite/accept?id=${invitation.id}`;
await sendViaMta({ to: email, /* …invitation email… */ });
},
}),
],
});
Wenn der aufgelöste Provider der eingebaute MTA ist (EMAIL_PROVIDER=mta),
sendet sendViaMta letztlich einen POST an ${MTA_API_URL}/send mit einem
Bearer ${MTA_API_KEY}-Header — derselbe MTA, der Ihre Kampagnen zustellt.
Jede andere Art, die der Katalog deklariert, trägt Einladungs-, Passwort-Reset-
und E-Mail-Änderungsnachrichten genauso gut, ausgewählt über EMAIL_PROVIDER
oder eine Provider-Route.
Client (auth-client.ts)
apps/web/app/lib/auth-client.ts baut den BetterAuth-Vue-Client. Es gibt eine
Trennung zwischen Web und Desktop: Der Web-Build proxyt Auth-Requests
same-origin (Nitro) über window.location.origin (mit Rückfall auf
NUXT_PUBLIC_SITE_URL während des SSR), während der Tauri-Desktop-Build direkt
mit der Convex-Site-URL des aktiven Workspace spricht und die Session über den
crossDomainClient in einem Header mitführt (persistiert im Schlüsselbund des
Betriebssystems).
import { createAuthClient } from 'better-auth/vue';
import { convexClient, crossDomainClient } from '@convex-dev/better-auth/client/plugins';
import { organizationClient } from 'better-auth/client/plugins';
function createWebAuthClient() {
const siteUrl =
typeof window !== 'undefined'
? window.location.origin
: (globalThis.process?.env?.NUXT_PUBLIC_SITE_URL ?? 'http://localhost:3000');
return createAuthClient({
baseURL: siteUrl,
plugins: [convexClient(), organizationClient()],
});
}
// Desktop (Tauri) adds the cross-domain plugin + keychain-backed storage.
export const authClient = isDesktopRuntime()
? createAuthClient({
baseURL: getActiveWorkspace()?.convexSiteUrl ?? 'http://localhost:3211',
plugins: [convexClient(), organizationClient(), crossDomainClient({ storage: keychainStorage })],
})
: createWebAuthClient();
// Auth methods.
export const { signIn, signUp, signOut, useSession, getSession } = authClient;
// Organization methods. `organization.create`/`organization.delete` are
// intentionally NOT re-exported — Owlat is single-org-per-instance.
export const {
organization: {
update: updateOrganization,
getFullOrganization,
list: listOrganizations,
setActive: setActiveOrganization,
checkSlug: checkOrgSlug,
inviteMember,
acceptInvitation,
rejectInvitation,
cancelInvitation,
removeMember,
updateMemberRole,
getActiveMember,
listMembers,
listInvitations,
leave: leaveOrganization,
},
useListOrganizations,
useActiveOrganization,
} = authClient;
Frontend-Composables
useAuth()
Das zentrale Authentifizierungs-Composable (apps/web/app/composables/useAuth.ts).
Es kapselt authClient.useSession() und stellt eine reaktive Session sowie die
Auth-Aktionen bereit.
const {
sessionData, // Full BetterAuth session payload | null
user, // Current user | null
currentSession, // session row (carries activeOrganizationId)
status, // 'pending' | 'authenticated' | 'unauthenticated' | 'error'
isAuthenticated, // Boolean (status === 'authenticated')
isPending, // Boolean (status === 'pending')
error, // Error | null
activeOrganizationId, // Active org ID from the session
hasActiveOrganization, // Boolean
signInWithEmail, // (email, password) => Promise
signUpWithEmail, // (email, password, name) => Promise
signOut, // () => Promise
forgotPassword, // (email) => Promise
resetPassword, // (newPassword, token) => Promise
refetch, // force-refresh the session
waitUntilReady, // await until status leaves 'pending'
} = useAuth();
Die Rolle des Aufrufers kommt nicht aus useAuth() — sie liegt auf
useOrganization().currentMemberRole.
useOrganization()
Verwaltung der Organisationsmitgliedschaft
(apps/web/app/composables/useOrganization.ts). Unterlegt mit einem gemeinsamen
useState, sodass sich alle Aufrufer pro Org-Wechsel einen Satz HTTP-Requests
teilen.
const {
// State
organization, // active org | null
organizationId, // active org ID | null
organizations, // list of orgs the user belongs to
members, // Ref<OrganizationMember[]>
invitations, // Ref<OrganizationInvitation[]> (pending only)
currentMemberRole, // 'owner' | 'admin' | 'editor' | null
isLoading,
isLoadingMembers,
// Permission checks
canManageMembers, // owner or admin
isOwner,
// Actions
fetchMembers, // ({ force }?) => Promise
invite, // (email, role, mailbox?) => Promise
remove, // (memberIdOrEmail) => Promise
updateRole, // (memberId, role) => Promise
cancelInvite, // (invitationId) => Promise
setActive, // (orgId) => Promise
update, // ({ name?, slug?, logo? }) => Promise
getFullOrganization, // () => Promise
} = useOrganization();
Das Rollenvokabular der App ist owner | admin | editor. BetterAuth speichert
editor intern als seine eingebaute Rolle member — useOrganization bildet an
der Grenze zwischen beiden ab, sodass Sie im App-Code immer editor sehen.
Authentifizierungsablauf
Registrierung
Die öffentliche Registrierung ist ausschließlich auf Einladung möglich. Die
Registrierungsseite (apps/web/app/pages/auth/register.vue) zeigt den Hinweis
"Registration is disabled", sofern sie nicht über einen Redirect von
/invite/accept?id=… erreicht wurde. Es gibt keinen Pfad, der bei der
Registrierung eine neue Organisation anlegt. Die erste Organisation wird einmalig
während der Installation von POST /seed/admin erzeugt (siehe
apps/api/convex/seedAdmin.ts); jeder weitere Nutzer tritt dieser Singleton-Org
über den Einladungsablauf als Editor/Admin/Owner bei.
<script setup>
import { api } from '@owlat/api';
const { signUpWithEmail } = useAuth();
const { run: createUserProfile } = useBackendOperation(api.auth.userProfiles.create);
const route = useRoute();
async function register() {
// 1. Create the BetterAuth user (also refreshes the session).
const result = await signUpWithEmail(email.value, password.value, name.value);
// 2. Create the Convex-side user profile (non-blocking; failures are toasted).
if (result?.user?.id) {
await createUserProfile({
authUserId: result.user.id,
email: email.value,
name: name.value,
});
}
// 3. Continue to the invite-accept page (or dashboard); BetterAuth attaches
// the new user to the singleton org with the invited role.
await navigateTo((route.query.redirect as string) || '/dashboard');
}
</script>
Login
<script setup>
const { signInWithEmail } = useAuth();
async function login() {
await signInWithEmail(email.value, password.value);
await navigateTo('/dashboard');
}
</script>
Geschützte Routen
<!-- In page component -->
<script setup>
definePageMeta({
middleware: 'auth',
});
</script>
Einladung annehmen
<!-- pages/invite/accept.vue -->
<script setup>
const { isAuthenticated } = useAuth();
const invitationId = useRoute().query.id;
async function acceptInvitation() {
await authClient.organization.acceptInvitation({
invitationId,
});
await navigateTo('/dashboard');
}
</script>
<template>
<div v-if="!isAuthenticated">
Please <NuxtLink to="/auth/login">log in</NuxtLink> to accept this invitation.
</div>
<div v-else>
<UiButton @click="acceptInvitation"> Accept Invitation </UiButton>
</div>
</template>
Session-Kontext im Backend
Alle Session-Helfer des Backends liegen in
apps/api/convex/lib/sessionOrganization.ts. Es gibt kein teamId irgendwo
im Session-Kontext des Backends — das Single-Org-Refactoring hat das Konzept der
Team-ID entfernt. Daten werden über die aktive Org gescopt, die die Helfer aus
dem Claim activeOrganizationId des Convex-JWT lesen (mit Rückfall auf die
BetterAuth-Session-Tabelle, solange ältere Tokens noch rotieren). Eine
Laufzeit-Invariante (assertSingletonOrgInvariant) bestätigt, dass genau eine
Org existiert und dass die aktive Org der Session mit ihr übereinstimmt.
Die meisten öffentlichen Funktionen rufen diese Helfer nie von Hand auf. Sie
nutzen die Wrapper authedQuery / authedMutation / authedAction /
adminMutation aus apps/api/convex/lib/authedFunctions.ts (siehe
Convex-Backend), die die Session-/Rollenauflösung und das
Berechtigungs-Gate für Sie ausführen. Greifen Sie zu den Helfern unten, wenn Sie
innerhalb eines gewrappten Handlers feinere Kontrolle benötigen.
Session-Helfer
| Helfer | Rückgabe | Einsatz |
|---|---|---|
getBetterAuthSession(ctx) | { userId, activeOrganizationId } | null | Weiches Lesen; null für anonyme Aufrufer |
getBetterAuthSessionWithRole(ctx) | ergänzt role: OrganizationRole | null; erzwingt die Singleton-Org-Invariante | Wenn Sie die Rolle des Aufrufers brauchen |
getUserIdFromSession(ctx) | string (userId) | Nur lesende Queries, die ausschließlich die User-ID benötigen; wirft, wenn nicht authentifiziert |
requireAuthenticatedIdentity(ctx) | Convex-UserIdentity | Anonyme Aufrufer in Mutations/Actions abweisen |
getMutationContext(ctx) | { userId, role } (MutationSessionContext) | Primärer Helfer für session-basierte Mutations; wirft, wenn nicht authentifiziert, keine aktive Org vorhanden oder kein Mitglied |
import {
getUserIdFromSession,
getMutationContext,
} from './lib/sessionOrganization';
// In a query: just need the user.
export const list = authedQuery({
handler: async (ctx) => {
const userId = await getUserIdFromSession(ctx);
// scope reads to the active org / userId
},
});
// In a mutation: need the role too. Note there is no teamId.
export const create = authedMutation({
args: { name: v.string() },
handler: async (ctx, args) => {
const { userId, role } = await getMutationContext(ctx);
requirePermission(hasPermission(role, 'contacts:manage'), 'Only owners/admins can create contacts');
},
});
Rollenbasierter Zugriff
Rollen
owner- Full access
- Delete team
- Transfer ownership
admin- Full access
- Manage members
- Cannot manage owners
editor- Create & edit content
- No team management
Berechtigungsprüfungen
Berechtigungen sind ein typisiertes scope:verb-System, das ebenfalls in
lib/sessionOrganization.ts definiert ist (es gibt kein Modul
./lib/permissions, und die alten Helfer isAdminRole/isOwnerRole wurden
entfernt). hasPermission(role, permission) bildet eine Rolle auf einen Boolean
ab; requirePermission(boolean, message?) ist das Assertion-Gate.
Die Permission-Union:
| Berechtigung | Gewährt an |
|---|---|
campaigns:send, campaigns:manage, campaigns:schedule | owner, admin |
templates:manage, automations:manage | owner, admin |
topics:manage, segments:manage | owner, admin |
media:manage, shareLinks:manage, imports:manage | owner, admin |
contacts:manage | owner, admin |
organization:manage, settings:manage | owner, admin |
chat:manage | owner, admin |
organization:delete | nur owner |
emails:test, knowledge:read, chat:participate | jedes Mitglied |
import {
hasPermission,
requirePermission,
requireAdminContext,
requireOwnerContext,
requireOrgPermission,
getMutationContext,
} from './lib/sessionOrganization';
// Boolean check.
if (hasPermission(role, 'settings:manage')) {
// ...
}
// Assertion: requirePermission takes a boolean + message (NOT a role).
const { role } = await getMutationContext(ctx);
requirePermission(hasPermission(role, 'settings:manage'), 'Only owners/admins can change settings');
Für häufige Fälle bevorzugen Sie die übergeordneten Gates, die den Kontext holen und in einem Aufruf prüfen:
// Admin (owner/admin) — wraps getMutationContext, mutation-only.
const session = await requireAdminContext(ctx);
// Owner only.
const session = await requireOwnerContext(ctx);
// Any typed permission, works in queries OR mutations (e.g. admin-gated reads).
const session = await requireOrgPermission(ctx, 'settings:manage');
Authentifizierung per API-Key
Öffentliche REST-Endpoints authentifizieren sich mit einem API-Key statt mit
einer Session. Die Logik liegt in apps/api/convex/auth/apiAuth.ts. Keys sind
mit lm_live_ präfigiert, werden ausschließlich als SHA-256-Hash gespeichert und
sind auf 10 Requests pro Sekunde und Key rate-limitiert.
authenticateApiRequest(ctx, request) liefert eine diskriminierte Union zurück —
der Diskriminator ist success, und im Erfolgsfall erhalten Sie eine keyId, die
scopes des Keys und rateLimit (es gibt kein teamId; Keys sind
key-gescopt):
type ApiAuthResponse =
| { success: true; keyId: Id<'apiKeys'>; scopes: string[]; rateLimit: RateLimitHeaders }
| { success: false; error: string; status: number; retryAfter?: number };
Der idiomatische Weg, einen Endpoint zu bauen, ist createAuthenticatedHandler:
Er authentifiziert den Request, behandelt den CORS-Preflight, injiziert
{ keyId, scopes, rateLimit } und hängt Rate-Limit-Header an die Antwort. Keys
tragen Scopes, die über requireScope(auth, scope) durchgesetzt werden:
import { createAuthenticatedHandler, requireScope } from './auth/apiAuth';
export const handleRequest = createAuthenticatedHandler(async (ctx, request, auth) => {
// auth.keyId — the validated API key ID
// auth.scopes — the scopes granted to this key
// auth.rateLimit — { limit, remaining, reset }
const denied = requireScope(auth, 'contacts:read', request.headers.get('Origin'));
if (denied) return denied; // 403 when the key lacks the scope
return jsonResponse({ ok: true });
});
Wenn Sie authenticateApiRequest direkt aufrufen, verzweigen Sie über success:
const auth = await authenticateApiRequest(ctx, request);
if (!auth.success) {
return errorResponse(auth.status === 429 ? 'rate_limited' : 'unauthenticated', auth.error);
}
// auth.keyId, auth.rateLimit are now available
API-Keys werden im Dashboard erstellt und verwaltet — siehe API-Keys & Webhooks und die Referenz der Authentifizierungs-API.