Appearance
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.
V1 baseline decision
coaches can register or log in with Keycloak email/password, Google, or Apple. Telegram is a conditional federated provider pending the OIDC sign-in spike; its consented phone claim yields a verified phone. Email-based providers require verified email. WhatsApp is not a login provider — WhatsApp and Telegram messaging are only delivery channels for invitations and one-time codes.
Tags: 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
- Coach starts registration from web or coach mobile and chooses email/password, Google, Apple, or Telegram (Telegram conditional pending the OIDC spike).
- Client sends the user through Keycloak using PKCE. Keycloak verifies the credential or provider identity.
- Client calls
POST /api/v1/auth/coach-registrationwith the OIDC access token, profile fields, locale, timezone, and terms version. - API validates token issuer, audience, email or phone verification state, provider subject, and duplicate identity constraints.
- Inside one transaction, API creates
User,AuthIdentity,CoachProfilewithout a public handle, defaultCoachWorkspace, and owner membership. - API emits
CoachRegistered, writes audit history, and returns the app session context.
Coach Login
- Coach authenticates with Keycloak through web or mobile PKCE.
- Client calls
GET /api/v1/auth/meusing the access token. - API resolves
AuthIdentity.provider + providerSubjectto a CoachMeUser. - API rejects disabled, pending-deletion, or non-coach users before returning app permissions.
- API updates session telemetry and returns active workspace, coach profile, roles, feature flags, and onboarding state.
Idempotent registration
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, optional public handle generated during profile setup, professional metadata, locale, and onboarding status.CoachWorkspace: default business workspace for the coach; owns clients, templates, billing, and settings. Its slug is internal-only for V1 baseline.WorkspaceMembership: role relationship between a user and a workspace, initiallyOWNER.AppSession: device/app session telemetry and revocation bridge for CoachMe clients.
Value Objects & Rules
ProviderRef: provider, subject, issuer, tenant, and verification metadata.EmailAddressandPhoneNumber: 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
COACHrole 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, deletion_requested_at, deleted_at, deleted_by_user_id | Unique lower email when present; unique normalized phone when present; indexes on status and deleted_at; 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, unlinked_at | Unique active (provider, issuer, provider_subject) where unlinked_at is null; 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, deleted_at | Unique active user_id; unique active public_handle when present; index on onboarding_status. Registration leaves public_handle null until profile setup. |
coach_workspaces | Business container for clients, templates, billing, and settings. | id, owner_user_id, name, slug, status, settings jsonb, created_at, deleted_at, deleted_by_user_id | Unique active slug; indexes on owner_user_id and deleted_at; status check ACTIVE, SUSPENDED, ARCHIVED. Slugs are server-generated internal identifiers for V1 baseline, not coach-visible route params. |
workspace_memberships | Role assignment inside a coach workspace. | id, workspace_id, user_id, role, status, joined_at, ended_at | Unique active (workspace_id, user_id); indexes on user_id and workspace_id; membership removal is represented by status and ended_at. |
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; retained for 30 days before purge or archival. |
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. |
Soft Deletion Policy
Use soft deletion for business records with their own lifecycle, not as a blanket column on every table.
Rule Of Thumb
- Aggregate roots and independently managed business entities get
deleted_at, optionaldeleted_by_user_id, and a reason or metadata field when support/admin context matters. - Owned child rows inherit visibility from their parent unless the product lets users remove or restore the child independently.
- Relationship rows use lifecycle state such as
ACTIVE,INVITED,ENDED, orREVOKEDinstead of generic deletion when the relationship history is meaningful. - Security, legal, audit, and outbox rows are append-only. They use retention, archival, or anonymization jobs rather than soft delete.
IdentityAccess Application
users: soft delete throughPENDING_DELETION,deletion_requested_at, anddeleted_at; login rejects both pending and deleted users.coach_profilesandcoach_workspaces: soft delete because they are user-visible business records and may need support recovery.auth_identities: useunlinked_atrather thandeleted_atso account recovery and provider relinking rules are explicit.workspace_memberships: use status plusended_at; membership history is part of the permission audit trail.app_sessions: userevoked_atand retention cleanup, not soft delete.terms_acceptancesandoutbox_events: never soft delete from application workflows; purge or archive only through retention policy.auth_audit_events: never soft delete from application workflows; retain for 30 days, then purge or archive through a scheduled retention job.
Soft-delete queries
Default queries should exclude rows where deleted_at is not null. Admin/support queries must opt in to deleted rows explicitly, and uniqueness constraints should be partial only when the business allows a deleted value, such as a handle or slug, to be reused.
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
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(),
deletionRequestedAt: timestamp("deletion_requested_at", { withTimezone: true }),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
deletedByUserId: uuid("deleted_by_user_id"),
}, (table) => ({
emailUnique: uniqueIndex("users_primary_email_lower_uq")
.on(sql`lower(${table.primaryEmail})`)
.where(sql`${table.primaryEmail} is not null and ${table.deletedAt} is null`),
phoneUnique: uniqueIndex("users_primary_phone_uq")
.on(table.primaryPhone)
.where(sql`${table.primaryPhone} is not null and ${table.deletedAt} is null`),
activeIdx: index("users_active_idx").on(table.status, table.deletedAt),
}));
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 }),
unlinkedAt: timestamp("unlinked_at", { withTimezone: true }),
}, (table) => ({
providerSubjectUnique: uniqueIndex("auth_identities_provider_subject_uq")
.on(table.provider, table.issuer, table.providerSubject)
.where(sql`${table.unlinkedAt} is null`),
userIdx: index("auth_identities_user_idx").on(table.userId),
}));Domain aggregate sketch
packages/domain/src/identity-access/user.ts
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
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
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
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.
- Purge or archive
auth_audit_eventsolder than 30 days according to the deployment retention policy.
Permissions & Access Rules
Provider authentication is necessary but not sufficient. Every coach feature checks application role and workspace membership.
- Only users with
COACHrole andACTIVEstatus 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
OWNERmembership. - Suspended users keep their identities linked but receive
403 USER_SUSPENDEDfrom 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/meon 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 & Resolved 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
COACHrole, profile, workspace, owner membership, terms acceptance, and event. - API integration: repeated registration with same provider subject is idempotent.
- API integration: suspended coach receives
403from/auth/me. - API integration: provider discovery returns enabled V1 baseline providers: email/password, Google, and Apple, plus Telegram where the OIDC spike is proven.
- API integration: Telegram registration accepts verified phone identity from a consented phone claim without requiring email.
- 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.
Resolved V1 Baseline Decisions
- Email/password is enabled directly in Keycloak for V1 baseline.
- V1 baseline login providers are email/password, Google, and Apple, with Telegram conditional pending the OIDC spike. WhatsApp is not a login provider. Phone-as-login is deferred (needs a custom Keycloak authenticator, not a delivery channel).
- Coach public handles are not required at registration; they are generated later during profile setup.
- Auth audit events are retained for 30 days before purge or archival.
- Workspace slugs are internal-only identifiers for V1 baseline. Coach-facing routes and API contracts continue to use workspace IDs.