Skip to content
Merged
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
2 changes: 2 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import { previewRoutes } from './routes/preview.js';
import { attachmentRoutes } from './routes/attachments.js';
import { chatRoutes } from './routes/chat.js';
import { moderationRoutes } from './routes/moderation.js';
import { webhookRoutes } from './routes/webhooks.js';
import { samlRoutes } from './routes/saml.js';
import { internalRoutes } from './routes/internal.js';

Expand Down Expand Up @@ -203,6 +204,7 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise<FastifyInsta
await fastify.register(attachmentRoutes);
await fastify.register(chatRoutes);
await fastify.register(moderationRoutes);
await fastify.register(webhookRoutes);
await fastify.register(samlRoutes);
await fastify.register(internalRoutes);

Expand Down
86 changes: 86 additions & 0 deletions apps/api/src/auth/github-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ export interface GitHubUser {
readonly login: string;
readonly name: string | null;
readonly avatar_url?: string;
/** Reputation facts from the same /user response (specs/api/auth.md step 5). */
readonly created_at: string | null;
readonly public_repos: number | null;
readonly followers: number | null;
readonly following: number | null;
readonly type: string | null;
}

export type GitHubProbeStatus = 'ok' | 'gone';

export interface GitHubProbeResult {
readonly status: GitHubProbeStatus;
/** Present when status is `ok`. */
readonly user: GitHubUser | null;
}

export interface GitHubEmail {
Expand Down Expand Up @@ -153,14 +167,65 @@ export async function fetchGitHubUser(accessToken: string): Promise<GitHubUser>
if (!body || typeof body.id !== 'number' || typeof body.login !== 'string') {
throw new GitHubApiError('GitHub /user returned unexpected shape', 'github_unreachable');
}
return toGitHubUser(body as Partial<GitHubUser> & { id: number; login: string });
}

function toGitHubUser(body: Partial<GitHubUser> & { id: number; login: string }): GitHubUser {
return {
id: body.id,
login: body.login,
name: typeof body.name === 'string' ? body.name : null,
...(typeof body.avatar_url === 'string' ? { avatar_url: body.avatar_url } : {}),
created_at: typeof body.created_at === 'string' ? body.created_at : null,
public_repos: typeof body.public_repos === 'number' ? body.public_repos : null,
followers: typeof body.followers === 'number' ? body.followers : null,
following: typeof body.following === 'number' ? body.following : null,
type: typeof body.type === 'string' ? body.type : null,
};
}

/**
* Is the linked GitHub account still there? `GET /user/{id}` authenticated
* with the OAuth app's client credentials (5,000 req/h). GitHub answers 404
* once it has deleted or suspended the account; that is the signal. Any other
* non-2xx throws so the caller keeps its previous record rather than
* misreading an outage as a verdict.
*/
export async function probeGitHubUser(
githubUserId: number,
clientId: string,
clientSecret: string,
opts: { readonly timeoutMs?: number } = {},
): Promise<GitHubProbeResult> {
const url = `https://api.github.com/user/${githubUserId}`;
const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
let res: Response;
try {
res = await fetch(url, {
method: 'GET',
headers: {
Authorization: `Basic ${basic}`,
Accept: 'application/vnd.github+json',
'User-Agent': USER_AGENT,
},
signal: AbortSignal.timeout(opts.timeoutMs ?? 4000),
});
} catch (err) {
throw new GitHubApiError(`GitHub API transport error: ${url}`, 'github_unreachable', { cause: err });
}
if (res.status === 404) return { status: 'gone', user: null };
if (!res.ok) {
throw new GitHubApiError(`GitHub API ${url} returned ${res.status}`, 'github_unreachable', {
status: res.status,
});
}
const body = (await res.json().catch(() => null)) as Partial<GitHubUser> | null;
if (!body || typeof body.id !== 'number' || typeof body.login !== 'string') {
throw new GitHubApiError('GitHub /user/{id} returned unexpected shape', 'github_unreachable');
}
return { status: 'ok', user: toGitHubUser(body as Partial<GitHubUser> & { id: number; login: string }) };
}

export async function fetchGitHubEmails(accessToken: string): Promise<GitHubEmail[]> {
const body = await ghGet(EMAILS_URL, accessToken);
if (!Array.isArray(body)) {
Expand Down Expand Up @@ -197,6 +262,12 @@ export interface ResolvedGitHubIdentity {
readonly name: string | null;
readonly emails: readonly GitHubEmail[];
readonly primaryEmail: string | null;
/**
* The full /user snapshot, kept so sign-in can record reputation facts.
* Absent when the identity was rebuilt from a claim-pending token rather
* than a live GitHub response.
*/
readonly user?: GitHubUser;
}

export function resolveIdentitySnapshot(
Expand All @@ -211,5 +282,20 @@ export function resolveIdentitySnapshot(
name: user.name,
emails: verified,
primaryEmail: primary?.email.toLowerCase() ?? null,
user,
};
}

/** Shape the reputation facts for the private profile (specs/behaviors/private-storage.md). */
export function githubFactsFrom(user: GitHubUser, status: GitHubProbeStatus, checkedAt: string) {
return {
login: user.login,
accountCreatedAt: user.created_at,
publicRepos: user.public_repos,
followers: user.followers,
following: user.following,
type: user.type,
status,
checkedAt,
};
}
7 changes: 7 additions & 0 deletions apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ export const EnvSchema = z.object({
CFP_NOTIFICATION_FROM: z
.string()
.default('Code for Philly <notifications@codeforphilly.org>'),
/**
* Shared secret Postmark presents on the bounce webhook (basic-auth password
* or bearer token). Unset → POST /api/_webhooks/postmark/bounce answers 503.
* See specs/api/webhooks.md.
*/
POSTMARK_WEBHOOK_SECRET: z.string().min(16).optional(),
});

export type Env = z.infer<typeof EnvSchema>;
Expand Down Expand Up @@ -144,6 +150,7 @@ export const envJsonSchema = {
CFP_SITE_HOST: { type: 'string', default: 'codeforphilly.org' },
POSTMARK_SERVER_TOKEN: { type: 'string' },
POSTMARK_MESSAGE_STREAM: { type: 'string', default: 'outbound' },
POSTMARK_WEBHOOK_SECRET: { type: 'string', minLength: 16 },
CFP_NOTIFICATION_FROM: {
type: 'string',
default: 'Code for Philly <notifications@codeforphilly.org>',
Expand Down
40 changes: 31 additions & 9 deletions apps/api/src/plugins/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { TagWriteService } from '../services/tag.write.js';
import { GitHubAccountService } from '../services/github-account.js';
import { AccountClaimService } from '../services/account-claim.js';
import { ModerationService, ModerationWriteService } from '../services/moderation.js';
import { probeGitHubUser } from '../auth/github-client.js';
import { LoggingNotifier, type Notifier } from '../notify/index.js';
import { EmailNotifier } from '../notify/email-notifier.js';
import { PostmarkTransport } from '../notify/postmark-transport.js';
Expand Down Expand Up @@ -110,15 +111,36 @@ async function servicesPlugin(fastify: FastifyInstance): Promise<void> {
tagsWrite: new TagWriteService(state),
githubAccount,
accountClaim: new AccountClaimService(state, fastify.store.private, githubAccount),
moderation: new ModerationService(state, fastify.store.private, (personId) => {
// Newest sign-in from session metadata; the auth plugin decorates it
// after this one registers, so resolve lazily per call.
let latest: string | null = null;
for (const m of fastify.sessionMetadata?.getAll(personId) ?? []) {
if (!latest || m.issuedAt > latest) latest = m.issuedAt;
}
return latest;
}),
moderation: new ModerationService(
state,
fastify.store.private,
(personId) => {
// Session facts from session metadata; the auth plugin decorates it
// after this one registers, so resolve lazily per call.
let latest: string | null = null;
let count = 0;
for (const m of fastify.sessionMetadata?.getAll(personId) ?? []) {
count += 1;
if (!latest || m.issuedAt > latest) latest = m.issuedAt;
}
return { lastLoginAt: latest, count };
},
{
log: fastify.log,
// The roster re-checks linked GitHub accounts against the API using the
// OAuth app's client credentials; without them the probe is simply off.
...(fastify.config.GITHUB_OAUTH_CLIENT_ID && fastify.config.GITHUB_OAUTH_CLIENT_SECRET
? {
probe: (githubUserId: number) =>
probeGitHubUser(
githubUserId,
fastify.config.GITHUB_OAUTH_CLIENT_ID as string,
fastify.config.GITHUB_OAUTH_CLIENT_SECRET as string,
),
}
: {}),
},
),
moderationWrite: new ModerationWriteService(state),
});
}
Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/routes/moderation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { ok, paginated } from '../lib/response.js';
import { ApiNotFoundError, ApiValidationError } from '../lib/errors.js';
import { getCallerSession } from '../services/permissions.js';
import { buildTransactionOptions } from '../store/commit-meta.js';
import type { VoteFilter } from '../services/moderation.js';
import type { MemberOrigin, VoteFilter } from '../services/moderation.js';

function requireStaffOr404(request: FastifyRequest): void {
const level = request.session.accountLevel;
Expand All @@ -34,6 +34,7 @@ export async function moderationRoutes(fastify: FastifyInstance): Promise<void>
properties: {
q: { type: 'string' },
vote: { type: 'string', enum: ['none', 'spam', 'legit'] },
origin: { type: 'string', enum: ['imported', 'signed-up'] },
joinedAfter: { type: 'string' },
joinedBefore: { type: 'string' },
includeDeactivated: { type: 'boolean' },
Expand All @@ -51,6 +52,7 @@ export async function moderationRoutes(fastify: FastifyInstance): Promise<void>
const result = await fastify.services.moderation.listMembers({
q: q['q'] as string | undefined,
vote: q['vote'] as VoteFilter | undefined,
origin: q['origin'] as MemberOrigin | undefined,
joinedAfter: q['joinedAfter'] as string | undefined,
joinedBefore: q['joinedBefore'] as string | undefined,
includeDeactivated: q['includeDeactivated'] as boolean | undefined,
Expand Down
22 changes: 22 additions & 0 deletions apps/api/src/routes/saml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,13 +371,16 @@ async function handleSpInitiatedSso(
{ relayState, customTagReplacement },
);

await stampSlackSso(fastify, person.id);

const samlResponse = bindingCtx.context;
const actionUrl =
'entityEndpoint' in bindingCtx && typeof bindingCtx.entityEndpoint === 'string'
? bindingCtx.entityEndpoint
: acsUrl;
const replyRelayState = 'relayState' in bindingCtx ? bindingCtx.relayState : relayState;

await stampSlackSso(fastify, person.id);
return reply.header('Content-Type', 'text/html; charset=utf-8').send(
renderPostForm({
actionUrl,
Expand All @@ -387,6 +390,22 @@ async function handleSpInitiatedSso(
);
}

/**
* Record that the IdP just vouched for this person to Slack. Best-effort: a
* private-store hiccup must not turn a successful assertion into an error.
* specs/api/saml.md → "Slack SSO stamp".
*/
async function stampSlackSso(fastify: FastifyInstance, personId: string): Promise<void> {
try {
const profile = await fastify.store.private.getProfile(personId);
if (!profile) return;
const now = new Date().toISOString();
await fastify.store.private.putProfile({ ...profile, lastSlackSsoAt: now, updatedAt: now });
} catch (err) {
fastify.log.warn({ err, personId }, 'could not stamp lastSlackSsoAt');
}
}

// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -487,6 +506,7 @@ export async function samlRoutes(fastify: FastifyInstance): Promise<void> {
);

// PostBindingContext.context holds the base64-encoded signed Response.
await stampSlackSso(fastify, person.id);
const samlResponse = bindingCtx.context;
const relayState = 'relayState' in bindingCtx ? bindingCtx.relayState : query.redir;
const actionUrl =
Expand Down Expand Up @@ -640,6 +660,8 @@ export async function samlRoutes(fastify: FastifyInstance): Promise<void> {
{ relayState: resumeClaims.relayState, customTagReplacement },
);

await stampSlackSso(fastify, person.id);

const samlResponse = bindingCtx.context;
const actionUrl =
'entityEndpoint' in bindingCtx && typeof bindingCtx.entityEndpoint === 'string'
Expand Down
Loading
Loading