s-m-r-t
Open the s-m-r-t source on GitHub Switch to dark color scheme
← Foundations

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.

Membership
UserProfileTenantRolePermission
Authentication, product identity, organization, and access remain separate.

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.
src/routes/auth/[provider]/callback/+server.ts
typescript
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.
src/routes/auth/[provider]/callback/+server.ts
typescript
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.
src/routes/api/cli/auth/token/+server.ts
typescript
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.