Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 30 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ Add these slash commands with their request URLs:
- `/counter` → `<backend-url>/counter`
- `/repstats` → `<backend-url>/rep/get`
- `/walkie` → `<backend-url>/walkie`
- `/start` → `<backend-url>/start`
- `/stop` → `<backend-url>/stop`
- `/bathroom` → `<backend-url>/bathroom`

**Important:** Check `Escape Channels, users and links sent to your app` for all commands.

Expand All @@ -70,7 +73,7 @@ Add these slash commands with their request URLs:

#### OAuth & Permissions

- **Redirect URLs (for search/auth UI):** `http://localhost:3001` (dev), or your deployed frontend URL
- **Redirect URLs (for search/auth UI):** `http://localhost:3000/auth/slack/callback` (backend callback), plus your deployed callback URL when applicable. This must exactly match `SLACK_REDIRECT_URI`.
- **Scopes:**
- `admin`
- `channels:history`
Expand Down Expand Up @@ -114,14 +117,15 @@ MUZZLE_BOT_TOKEN=xoxb-your-bot-token
MUZZLE_BOT_USER_TOKEN=xoxp-your-user-token
MUZZLE_BOT_SIGNING_SECRET=your-signing-secret

# Slack OAuth (for search/auth UI login)
# Slack OAuth (for search/auth UI login + bathroom timer)
SLACK_CLIENT_ID=your-client-id
SLACK_CLIENT_SECRET=your-client-secret
SLACK_REDIRECT_URI=http://localhost:3000/auth/slack/callback

# Search & Auth
ALLOWED_TEAM_DOMAIN=your-workspace-domain
SEARCH_FRONTEND_URL=http://localhost:3001
SEARCH_FRONTEND_URL=http://localhost:5173
SESSION_SECRET=generate-a-random-secret-key
SEARCH_AUTH_SECRET=generate-a-random-secret-key

# MySQL / TypeORM
Expand Down Expand Up @@ -162,7 +166,7 @@ VITE_API_BASE_URL=http://localhost:3000
# Install dependencies (installs all workspaces)
npm install

# Start backend development server
# Start backend development server (creates/updates bathroom_users and bathroom_timers when TYPEORM_SYNCHRONIZE=true)
npm run start

# In a new terminal, start frontend development server
Expand All @@ -172,6 +176,23 @@ npm run dev -w @mocker/frontend
# Frontend (search UI): http://localhost:5173
```

### Bathroom Timer Notes

- Authentication is handled by Slack OAuth at `GET /auth/slack` and `GET /auth/slack/callback`.
- The callback now creates an HTTP-only session cookie for frontend requests. Set a strong `SESSION_SECRET` in every environment, and use `NODE_ENV=production` so the cookie is marked `Secure` outside local development.
- Bathroom timer endpoints live under `/api`:
- `GET /api/me`
- `POST /api/timer/start`
- `POST /api/timer/stop`
- `GET /api/leaderboard?date=YYYY-MM-DD`
- Signed Slack slash commands can also manage the timer directly:
- `/start` starts the caller's timer
- `/stop` stops the caller's timer
- `/bathroom [daily|weekly|monthly|lifetime|all]` shows the requested leaderboard(s)
- The daily bathroom leaderboard uses **UTC calendar days** and counts the overlapping portion of each completed timer within the selected UTC day.
- Slack leaderboard ranges are also UTC-based; `/bathroom` defaults to showing daily, weekly, monthly, and lifetime sections sorted from least to most total bathroom time.
- Only one active bathroom timer is allowed per Slack user at a time.

### 5. Testing

```bash
Expand Down Expand Up @@ -245,11 +266,13 @@ docker logs <container-id> | jq .
- **Reputation Tracking** - Rep stats, reactions, achievements
- **Event Handling** - Real-time reactions, team join events, message history

### Search & Auth System (New)
### Search, Auth & Bathroom Timer

- **OAuth Login** - Users authenticate via Slack to access the search UI
- **OAuth Login** - Users authenticate via Slack to access the UI and bathroom timer
- **Team-Scoped Search** - Messages are filtered by `teamId` to prevent cross-workspace leakage
- **Session Tokens** - HMAC-signed custom session tokens (base64url payload + signature, not JWT), issued after OAuth callback, required for all search requests
- **Cookie Sessions** - Slack OAuth now sets an HTTP-only session cookie for frontend requests, with bearer-token compatibility retained for existing clients
- **Bathroom Timer** - Authenticated users can start or stop exactly one active bathroom timer and view their current timer state
- **Daily Leaderboard** - The home page shows a per-day bathroom leaderboard sorted from least to most total bathroom time
- **Rate Limiting** - Auth endpoints: 20/15min, Search endpoints: 60/1min

### AI Features (Optional)
Expand Down
1 change: 1 addition & 0 deletions packages/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ NODE_ENV=development
# Search/Auth UI
# =========================
SEARCH_FRONTEND_URL=http://localhost:5173
SESSION_SECRET=replace-with-a-long-random-secret
SEARCH_AUTH_SECRET=replace-with-a-long-random-secret
ALLOWED_TEAM_DOMAIN=your-slack-team-id

Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/auth/auth.const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export const SLACK_TOKEN_URL = 'https://slack.com/api/oauth.v2.access';
export const SLACK_IDENTITY_URL = 'https://slack.com/api/users.identity';
export const OAUTH_STATE_COOKIE = 'oauth_state';
export const OAUTH_STATE_MAX_AGE_MS = 300000; // 5 minutes in milliseconds
export const SESSION_COOKIE = 'mocker_session';
33 changes: 30 additions & 3 deletions packages/backend/src/auth/auth.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ vi.mock('axios');
vi.mock('../shared/utils/session-token', async () => ({
createSessionToken: vi.fn().mockReturnValue('mock-session-token'),
}));
const upsertUserMock = vi.fn();

vi.mock('../bathroom/bathroom.persistence.service', async () => ({
BathroomPersistenceService: classMock(() => ({
upsertUser: upsertUserMock,
})),
}));

import { authController } from './auth.controller';

Expand All @@ -22,6 +29,7 @@ describe('authController', () => {

beforeEach(() => {
vi.clearAllMocks();
upsertUserMock.mockResolvedValue(undefined);
process.env = {
...OLD_ENV,
SLACK_CLIENT_ID: 'test-client-id',
Expand Down Expand Up @@ -72,14 +80,14 @@ describe('authController', () => {
expect(res.status).toBe(500);
});

it('redirects to frontend with token in hash on successful OAuth', async () => {
it('sets a session cookie and redirects to frontend home on successful OAuth', async () => {
(Axios.post as Mock).mockResolvedValue({
data: { ok: true, authed_user: { id: 'U123', access_token: 'xoxp-token' } },
});
(Axios.get as Mock).mockResolvedValue({
data: {
ok: true,
user: { id: 'U123', name: 'alice' },
user: { id: 'U123', name: 'alice', image_72: 'https://example.com/alice.png' },
team: { name: 'T123', id: 'T123' },
},
});
Expand All @@ -90,7 +98,15 @@ describe('authController', () => {
.query({ code: 'valid-code', state: TEST_STATE });

expect(res.status).toBe(302);
expect(res.headers.location).toContain('#token=mock-session-token');
expect(res.headers.location).toBe('http://localhost:5173');
expect(res.headers['set-cookie']).toEqual(
expect.arrayContaining([expect.stringContaining('mocker_session=mock-session-token')]),
);
expect(upsertUserMock).toHaveBeenCalledWith({
slackId: 'U123',
displayName: 'alice',
avatarUrl: 'https://example.com/alice.png',
});
});

it('redirects with auth_error=access_denied when state cookie is missing', async () => {
Expand Down Expand Up @@ -211,5 +227,16 @@ describe('authController', () => {
expect(res.status).toBe(302);
expect(res.headers.location).toContain('auth_error=server_error');
});

describe('POST /logout', () => {
it('clears the session cookie', async () => {
const res = await request(app).post('/logout');

expect(res.status).toBe(204);
expect(res.headers['set-cookie']).toEqual(
expect.arrayContaining([expect.stringContaining('mocker_session=;')]),
);
});
});
});
});
75 changes: 73 additions & 2 deletions packages/backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,67 @@ import express from 'express';
import Axios from 'axios';
import type { OauthV2AccessResponse, UsersIdentityResponse } from '@slack/web-api';
import { createSessionToken } from '../shared/utils/session-token';
import { TOKEN_TTL_MS } from '../shared/utils/session-token.const';
import { logError } from '../shared/logger/error-logging';
import { logger } from '../shared/logger/logger';
import { BathroomPersistenceService } from '../bathroom/bathroom.persistence.service';
import {
SLACK_AUTH_URL,
SLACK_TOKEN_URL,
SLACK_IDENTITY_URL,
OAUTH_STATE_COOKIE,
OAUTH_STATE_MAX_AGE_MS,
SESSION_COOKIE,
} from './auth.const';

export const authController: Router = express.Router();
const authLogger = logger.child({ module: 'AuthController' });
const bathroomPersistenceService = new BathroomPersistenceService();

function sessionCookieOptions(): { httpOnly: boolean; maxAge: number; sameSite: 'lax'; secure: boolean } {
return {
httpOnly: true,
maxAge: TOKEN_TTL_MS,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
};
}

function sessionCookieClearOptions(): { httpOnly: boolean; sameSite: 'lax'; secure: boolean } {
return {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
};
}

function getOptionalString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}

function getRecordString(record: Record<string, unknown> | undefined, key: string): string | undefined {
return record ? getOptionalString(record[key]) : undefined;
}

function isAllowedWorkspace(identityResponse: UsersIdentityResponse): boolean {
const allowedTeam = process.env.ALLOWED_TEAM_DOMAIN;
if (!allowedTeam) {
return true;
}

const teamId = identityResponse.team?.id;
const teamName = identityResponse.team?.name;

return (
teamId === allowedTeam ||
teamName === allowedTeam ||
`${teamName ?? ''}.slack.com` === allowedTeam.replace(/^https?:\/\//, '')
);
}

function getCookieValue(req: Request, name: string): string | undefined {
const cookieHeader = req.headers.cookie;
Expand Down Expand Up @@ -107,7 +156,7 @@ authController.get('/slack/callback', (req, res) => {

const teamId = identityResponse.data.team?.id;
const userId = identityResponse.data.user?.id;
if (!identityResponse.data.ok || !userId || !teamId || teamId !== process.env.ALLOWED_TEAM_DOMAIN) {
if (!identityResponse.data.ok || !userId || !teamId || !isAllowedWorkspace(identityResponse.data)) {
logError(authLogger, 'Unauthorized Slack workspace attempted to authenticate', {
teamId,
userId,
Expand All @@ -116,10 +165,32 @@ authController.get('/slack/callback', (req, res) => {
return;
}

const userPayload = identityResponse.data.user;
const userPayloadRecord = isRecord(userPayload) ? userPayload : undefined;
const displayName =
getOptionalString(userPayload?.name) || getRecordString(userPayloadRecord, 'real_name') || userId;
const avatarUrl =
getRecordString(userPayloadRecord, 'image_192') ??
getRecordString(userPayloadRecord, 'image_72') ??
getRecordString(userPayloadRecord, 'image_48') ??
null;

await bathroomPersistenceService.upsertUser({
slackId: userId,
displayName,
avatarUrl,
});

const sessionToken = createSessionToken(userId, teamId);
res.redirect(`${frontendUrl}#token=${sessionToken}`);
res.cookie(SESSION_COOKIE, sessionToken, sessionCookieOptions());
res.redirect(frontendUrl);
})().catch((e: unknown) => {
logError(authLogger, 'Slack OAuth callback failed', e, {});
res.redirect(`${frontendUrl}?auth_error=server_error`);
});
});

authController.post('/logout', (_req, res) => {
res.clearCookie(SESSION_COOKIE, sessionCookieClearOptions());
res.status(204).send();
});
Loading