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.
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, WhatsApp, or Telegram.
- 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,CoachProfile, 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.
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, 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 |
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.tsexport 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.tsexport 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.tsexport 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
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 & 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
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. - 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.