Foundation 03
Keep login records separate from people
Users handle sign-in and sessions. Profiles represent the person, organization, or agent your product knows about.
Use User for authentication
User owns authentication identity, account state, sessions, and the link to a profile. The session records the active tenant and the resolved permissions for each request.
- One user can belong to more than one tenant.
- Switching tenants checks active membership and rotates the session.
- Access requests can become users, memberships, and tenants after approval.
Use Profile for product identity
Profiles can represent people, organizations, bots, or public identities. They can exist before an account is created and can participate in typed, directional relationships.
Sign in through an identity provider
Declare one or more OIDC providers in configuration. Mount a login route and a callback route. The handlers own the protocol. Each login gets its own state, nonce, and PKCE verifier. The callback verifies state and the authorization-response issuer. It also verifies the signed ID token and nonce before it reads a claim.
- The PKCE challenge method is always S256.
- An ID token with no email claim falls back to the provider UserInfo endpoint.
- On success the handler links an OIDC identity, resolves the user, and sets the normal session cookie.
- A failed login without a configured redirect returns a plain 401 rather than describing the account.
import { createOidcCallbackHandler } from '@happyvertical/smrt-users/sveltekit';
export const GET = createOidcCallbackHandler({
db: { type: 'postgres', url: process.env.DATABASE_URL! },
successRedirect: '/dashboard'
});The first identity binding is a decision you make
A new provider identity can resolve to a person record that already has an owning user. Sign-in then stops before it creates an account, identity link, or session. This behavior is deliberate. Matching email addresses do not prove that the same person is behind both. An application with an invitation or approval workflow can authorize that first binding explicitly.
- Return undefined to keep the fail-closed default, or null to reject the login outright.
- The framework reloads both records by id inside the same transaction rather than trusting what you returned.
- The provider must report the email as verified, and an identity that is already bound can never be rebound.
- A race can run the hook twice, so it has to be safe to repeat.
import { ProfileCollection } from '@happyvertical/smrt-profiles';
import { createOidcCallbackHandler } from '@happyvertical/smrt-users/sveltekit';
export const GET = createOidcCallbackHandler({
db,
authorizeProfileOwner: async ({ claims, db, users }) => {
const approval = await findApprovedInvite({ db, email: claims.email });
if (!approval) return undefined; // keep the secure default
const profiles = await ProfileCollection.create({ db });
const profile = await profiles.get({ id: approval.profileId });
const user = await users.get({ id: approval.userId });
if (!profile || !user) return null; // reject a stale approval
return { profile, user };
},
successRedirect: '/dashboard'
});Sign in from a terminal
Command-line tools use the device-code flow. The CLI starts a request and receives a secret device code, a short code the person reads aloud or types, and a verification URL. The person approves it in an already signed-in browser, and the CLI polls until it can exchange its device code for a bearer token.
- The device code is stored only as a hash; the short user code is what a human handles.
- The bearer token resolves to the same session context as a browser cookie, so permissions and tenant scope match.
- Approving is idempotent, and a request expires if nobody approves it in time.
- Because user codes are short, repeated failed approvals are rate limited per user.
import { createTerminalAuthTokenHandler } from '@happyvertical/smrt-users/sveltekit';
// The CLI polls this until it answers approved, then stores the token.
export const POST = createTerminalAuthTokenHandler({ db });Let the starter wire the common flow
Both starter paths include current session handling. The SaaS starter adds the finished onboarding, account, and tenant-management surfaces; the ground-up template keeps them visible as small examples you can change.