Authentication / IdentityAccess

Coach login and registration creates a verified coach account without coupling CoachMe identity to any one provider.

Keycloak owns credential verification and external provider handshakes. CoachMe owns the business identity: user status, coach profile, workspace membership, role assignment, audit trail, terms acceptance, and application access.

NestJS Keycloak OIDC Drizzle + PostgreSQL Coach Web + Mobile

Registration And Login Flows

The provider flow proves who the person is. The application flow decides whether that identity can become or access a CoachMe coach.

Coach Registration

  1. Coach starts registration from web or coach mobile and chooses email/password, Google, Apple, WhatsApp, or Telegram.
  2. Client sends the user through Keycloak using PKCE. Keycloak verifies the credential or provider identity.
  3. Client calls POST /api/v1/auth/coach-registration with the OIDC access token, profile fields, locale, timezone, and terms version.
  4. API validates token issuer, audience, email or phone verification state, provider subject, and duplicate identity constraints.
  5. Inside one transaction, API creates User, AuthIdentity, CoachProfile, default CoachWorkspace, and owner membership.
  6. API emits CoachRegistered, writes audit history, and returns the app session context.

Coach Login

  1. Coach authenticates with Keycloak through web or mobile PKCE.
  2. Client calls GET /api/v1/auth/me using the access token.
  3. API resolves AuthIdentity.provider + providerSubject to a CoachMe User.
  4. API rejects disabled, pending-deletion, or non-coach users before returning app permissions.
  5. API updates session telemetry and returns active workspace, coach profile, roles, feature flags, and onboarding state.
Registration must be idempotent by provider identity. A repeated registration call after a network retry returns the already-created coach context when the same verified provider subject is used.

Domain Model

IdentityAccess owns authentication identity mapping and role eligibility. CoachClientManagement can reference coach IDs later, but it does not create coach accounts.

Aggregates & Entities

  • User: aggregate root for application identity, lifecycle status, primary contact fields, and role assignments.
  • AuthIdentity: provider-specific identity linked to one user; unique by provider and provider subject.
  • CoachProfile: coach-facing profile, handle, professional metadata, locale, and onboarding status.
  • CoachWorkspace: default business workspace for the coach; supports future assistants, billing, and team permissions.
  • WorkspaceMembership: role relationship between a user and a workspace, initially OWNER.
  • AppSession: device/app session telemetry and revocation bridge for CoachMe clients.

Value Objects & Rules

  • ProviderRef: provider, subject, issuer, tenant, and verification metadata.
  • EmailAddress and PhoneNumber: normalized before uniqueness checks.
  • TermsAcceptance: immutable accepted version, timestamp, IP hash, and user agent.
  • UserStatus: ACTIVE, SUSPENDED, PENDING_DELETION.
  • One provider identity can belong to only one user; one coach profile belongs to only one user.
  • A coach cannot access coach surfaces until the user has COACH role and an active owner membership.
Model Owned By Important Methods Emits
User IdentityAccess registerCoach(), attachIdentity(), suspend(), acceptTerms() CoachRegistered, UserSuspended
CoachProfile IdentityAccess completeOnboardingStep(), renameDisplayName(), setTimezone() CoachProfileUpdated
AppSession IdentityAccess recordLogin(), revoke(), markTokenRefresh() CoachLoggedIn, SessionRevoked

Table Structure

PostgreSQL stores CoachMe business identity and audit records. Keycloak stores credentials, passwords, MFA settings, and external provider links.

Table Purpose Important Columns Constraints & Indexes
users Application identity for coaches, clients, admins, and future staff users. id, primary_email, primary_phone, display_name, status, roles text[], created_at, updated_at Unique lower email when present; unique normalized phone when present; index on status; check at least one contact method exists after registration.
auth_identities Maps provider identities to CoachMe users. id, user_id, provider, provider_subject, issuer, email_verified, phone_verified, last_login_at, raw_claims jsonb Unique (provider, issuer, provider_subject); index on user_id; JSON claims retained for support/debug with PII minimization.
coach_profiles Coach-specific profile and onboarding state. id, user_id, public_handle, bio, timezone, locale, onboarding_status, created_at Unique user_id; unique public_handle; index on onboarding_status.
coach_workspaces Business container for clients, templates, billing, assistants, and settings. id, owner_user_id, name, slug, status, settings jsonb, created_at Unique slug; index owner_user_id; status check ACTIVE, SUSPENDED, ARCHIVED.
workspace_memberships Role assignment inside a coach workspace. id, workspace_id, user_id, role, status, joined_at Unique active (workspace_id, user_id); indexes on user_id and workspace_id.
app_sessions CoachMe session telemetry and app-level revocation tracking. id, user_id, keycloak_session_id, client_app, device_id_hash, ip_hash, last_seen_at, revoked_at Index (user_id, last_seen_at desc); unique active keycloak_session_id when present.
terms_acceptances Immutable proof that the coach accepted required legal versions. id, user_id, terms_version, privacy_version, accepted_at, ip_hash, user_agent_hash Unique (user_id, terms_version, privacy_version); append-only.
auth_audit_events Security audit trail for registration, login, failed access, suspension, and provider linking. id, user_id, event_type, provider, actor_user_id, metadata jsonb, created_at Index (user_id, created_at desc); index on event_type for security review.
outbox_events Transactional event handoff for welcome notifications, analytics, and downstream sync. id, aggregate_type, aggregate_id, event_type, payload jsonb, status, available_at Index (status, available_at); consumed by BullMQ worker with retry policy.

API Contracts

All endpoints require a valid Keycloak token except provider callback URLs handled directly by Keycloak.

Endpoint Use Case Request Response
POST /api/v1/auth/coach-registration Complete CoachMe business registration after provider verification. displayName, timezone, locale, termsVersion, privacyVersion, optional workspaceName user, coachProfile, workspace, roles, onboardingStatus
GET /api/v1/auth/me Resolve app session context for web and coach mobile. Bearer access token. User identity, active workspace, permissions, onboarding state, feature flags.
POST /api/v1/auth/sessions/current/heartbeat Update app session telemetry without extending Keycloak token lifetime. clientApp, deviceId, appVersion, pushTokenId lastSeenAt, sessionStatus
POST /api/v1/auth/logout Revoke app session and ask client to clear local secure storage. sessionId or current token context. revokedAt
GET /api/v1/auth/providers Return enabled provider list per platform and region. Optional platform and locale. Provider keys, labels, scopes, and Keycloak authorization URLs.

Key Code Snippets

These snippets are implementation sketches for the planned TypeScript monorepo. Keep final code in the owning package, not inside controllers.

Drizzle schema shape

apps/api/src/identity-access/db/schema.ts
export const users = pgTable("users", {
  id: uuid("id").primaryKey().defaultRandom(),
  primaryEmail: text("primary_email"),
  primaryPhone: text("primary_phone"),
  displayName: text("display_name").notNull(),
  status: userStatus("status").notNull().default("ACTIVE"),
  roles: text("roles").array().notNull().default(sql`ARRAY[]::text[]`),
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
  emailUnique: uniqueIndex("users_primary_email_lower_uq")
    .on(sql`lower(${table.primaryEmail})`)
    .where(sql`${table.primaryEmail} is not null`),
  phoneUnique: uniqueIndex("users_primary_phone_uq")
    .on(table.primaryPhone)
    .where(sql`${table.primaryPhone} is not null`),
}));

export const authIdentities = pgTable("auth_identities", {
  id: uuid("id").primaryKey().defaultRandom(),
  userId: uuid("user_id").notNull().references(() => users.id),
  provider: text("provider").notNull(),
  issuer: text("issuer").notNull(),
  providerSubject: text("provider_subject").notNull(),
  emailVerified: boolean("email_verified").notNull().default(false),
  phoneVerified: boolean("phone_verified").notNull().default(false),
  rawClaims: jsonb("raw_claims").$type<Record<string, unknown>>().notNull(),
  lastLoginAt: timestamp("last_login_at", { withTimezone: true }),
}, (table) => ({
  providerSubjectUnique: uniqueIndex("auth_identities_provider_subject_uq")
    .on(table.provider, table.issuer, table.providerSubject),
  userIdx: index("auth_identities_user_idx").on(table.userId),
}));

Domain aggregate sketch

packages/domain/src/identity-access/user.ts
export class User extends AggregateRoot<UserId> {
  private constructor(private props: UserProps) {
    super(props.id);
  }

  static registerCoach(command: RegisterCoachCommand, identity: VerifiedProviderIdentity): User {
    if (!identity.emailVerified && !identity.phoneVerified) {
      throw new DomainError("COACH_IDENTITY_NOT_VERIFIED");
    }

    const user = new User({
      id: UserId.create(),
      displayName: command.displayName,
      primaryEmail: identity.email,
      primaryPhone: identity.phone,
      status: "ACTIVE",
      roles: ["COACH"],
      identities: [AuthIdentity.fromVerifiedProvider(identity)],
      terms: TermsAcceptance.accept(command.termsVersion, command.privacyVersion),
    });

    user.addEvent(new CoachRegistered({
      userId: user.id.value,
      provider: identity.provider,
      occurredAt: new Date(),
    }));

    return user;
  }
}

NestJS application service

apps/api/src/identity-access/use-cases/register-coach.ts
@Injectable()
export class RegisterCoachUseCase {
  constructor(
    private readonly identities: AuthIdentityRepository,
    private readonly users: UserRepository,
    private readonly workspaces: CoachWorkspaceRepository,
    private readonly tokenVerifier: OidcTokenVerifier,
    private readonly tx: TransactionRunner,
  ) {}

  async execute(input: RegisterCoachInput, token: AccessToken): Promise<CoachSessionDto> {
    const verifiedIdentity = await this.tokenVerifier.verify(token);

    return this.tx.run(async () => {
      const existing = await this.identities.findByProviderRef(verifiedIdentity.ref);
      if (existing) return this.users.getCoachSessionContext(existing.userId);

      const user = User.registerCoach(input, verifiedIdentity);
      const workspace = CoachWorkspace.createDefaultFor(user, input.workspaceName);

      await this.users.save(user);
      await this.workspaces.saveWithOwner(workspace, user.id);
      await this.users.saveEventsToOutbox(user);

      return this.users.getCoachSessionContext(user.id);
    });
  }
}

Controller and guard boundary

apps/api/src/identity-access/http/coach-auth.controller.ts
@Controller("/api/v1/auth")
export class CoachAuthController {
  constructor(
    private readonly registerCoach: RegisterCoachUseCase,
    private readonly sessionQuery: SessionContextQuery,
  ) {}

  @Post("coach-registration")
  @UseGuards(KeycloakBearerGuard)
  async register(@Body() body: RegisterCoachDto, @CurrentToken() token: AccessToken) {
    return this.registerCoach.execute(body, token);
  }

  @Get("me")
  @UseGuards(KeycloakBearerGuard)
  async me(@CurrentIdentity() identity: VerifiedProviderIdentity) {
    return this.sessionQuery.forProviderIdentity(identity.ref);
  }
}

Coach web client call

apps/web/src/app/register/actions.ts
export async function completeCoachRegistration(input: RegisterCoachForm) {
  const token = await authClient.getAccessToken();

  const response = await api.auth.completeCoachRegistration({
    headers: { Authorization: `Bearer ${token}` },
    body: {
      displayName: input.displayName.trim(),
      workspaceName: input.workspaceName.trim(),
      timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
      locale: input.locale,
      termsVersion: CURRENT_TERMS_VERSION,
      privacyVersion: CURRENT_PRIVACY_VERSION,
    },
  });

  sessionStore.set(response);
  redirect("/dashboard");
}

Events & Background Jobs

Registration side effects run after the database transaction commits through the outbox worker.

Domain / Integration Events

  • CoachRegistered: emitted when a new coach account and workspace are created.
  • CoachLoggedIn: emitted for security analytics and suspicious login detection.
  • SessionRevoked: emitted after logout, admin suspension, or forced security reset.
  • TermsAccepted: emitted when required legal versions are accepted.

Jobs

  • Send welcome email/push after CoachRegistered.
  • Provision default workspace settings and sample empty template folders.
  • Refresh Keycloak role mapping if app roles and realm roles diverge.
  • Run suspicious login checks by comparing provider, device hash, country, and failed attempts.

Permissions & Access Rules

Provider authentication is necessary but not sufficient. Every coach feature checks application role and workspace membership.

  • Only users with COACH role and ACTIVE status can enter coach web or coach mobile.
  • A coach can only access resources scoped to a workspace where they have active membership.
  • A newly registered coach receives one default workspace and OWNER membership.
  • Suspended users keep their identities linked but receive 403 USER_SUSPENDED from app APIs.
  • Duplicate provider subjects are never merged automatically; support tooling must handle account recovery explicitly.
  • Client invitation login cannot create a coach profile. Coach registration cannot create a coach-client relationship.

Web And Mobile Client Behavior

Both coach surfaces use the same API contracts, but platform storage and provider UX differ.

Coach Web

  • Use Keycloak authorization code with PKCE.
  • Store access token in memory and refresh through secure OIDC flow.
  • After registration, redirect to onboarding checklist or dashboard based on onboardingStatus.
  • Show account-exists recovery when provider identity is already linked.

Coach Mobile

  • Use Expo AuthSession for Keycloak PKCE.
  • Store refresh material only in platform secure storage.
  • Call /auth/me on cold start before hydrating coach screens.
  • Clear SQLite drafts and sync queues only after confirmed logout or account switch.

Client Mobile Impact

  • No direct client registration changes for this feature.
  • Shared mobile auth package should support app-specific allowed roles.
  • Client app rejects coach-only sessions with an app mismatch error.
  • Provider adapters remain shared across coach and client auth flows.

Test Scenarios & Open Decisions

Auth testing should cover duplicate identities, provider verification, idempotency, and role boundaries before UI polish.

Required Tests

  • Domain: unverified provider identity cannot register a coach.
  • Domain: registering creates COACH role, profile, workspace, owner membership, terms acceptance, and event.
  • API integration: repeated registration with same provider subject is idempotent.
  • API integration: suspended coach receives 403 from /auth/me.
  • Contract: web and coach mobile receive the same CoachSessionDto.
  • E2E: coach can register, refresh, close app/browser, reopen, and land in the correct workspace.
  • Security: client app cannot use a coach-only session to access client onboarding endpoints.

Open Decisions

  • Whether email/password is enabled directly in Keycloak for MVP or social/provider-only.
  • Whether WhatsApp and Telegram are login providers at MVP or phone verification links handled after account creation.
  • Whether coach public handles are required at registration or generated later during profile setup.
  • How long auth audit events are retained before archival.
  • Whether workspace slugs are coach-visible URLs or internal-only identifiers.