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
49 changes: 49 additions & 0 deletions docs/security/C292_PHASE1_AUTH_HARDENING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# C-292 Phase 1 — OAA Auth Hardening

## Scope

Phase 1 addresses the highest-risk OAA authentication issues from the C-292 mesh cleanup tracker.

## Changes

### JWT secret fail-closed

`src/lib/auth/jwt.ts` now:

- Requires `JWT_SECRET` in production.
- Rejects secrets shorter than 32 characters.
- Rejects the development fallback secret in production.
- Keeps the development fallback only for non-production local work.

### Magic link token logging removed

`src/lib/auth/authService.ts` now:

- Stops logging full magic login URLs by default.
- Logs only an email hash, token hash prefix, and expiration metadata.
- Allows full URL logging only in non-production with `ALLOW_DEV_MAGIC_LINK_LOGS=true`.

## Operator requirements

Production must set:

```txt
JWT_SECRET=<random 32+ character secret>
```

Recommended generation:

```bash
openssl rand -base64 48
```

Do not enable `ALLOW_DEV_MAGIC_LINK_LOGS` in production.

## Canon

Authentication must fail closed.

Magic links are credentials.
Credentials must not enter hosted logs.

We heal as we walk.
29 changes: 25 additions & 4 deletions src/lib/auth/authService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,29 @@ async function getPrisma(): Promise<PrismaClient> {
return prisma;
}

function allowDevMagicLinkLogs(): boolean {
return (
process.env.NODE_ENV !== 'production' &&
process.env.ALLOW_DEV_MAGIC_LINK_LOGS === 'true'
);
}

function logMagicLinkGenerated(email: string, tokenHash: string, expiresAt: Date, magicUrl?: string): void {
const emailHash = sha256(email.toLowerCase()).slice(0, 12);
const tokenHashPrefix = tokenHash.slice(0, 12);

if (allowDevMagicLinkLogs() && magicUrl) {
console.warn(
`[MagicLink:DEV_ONLY] emailHash=${emailHash} tokenHash=${tokenHashPrefix} expiresAt=${expiresAt.toISOString()} url=${magicUrl}`,
);
return;
}

console.log(
`[MagicLink] generated emailHash=${emailHash} tokenHash=${tokenHashPrefix} expiresAt=${expiresAt.toISOString()}`,
);
}

export interface RegisterInput {
handle: string;
email: string;
Expand Down Expand Up @@ -187,12 +210,10 @@ export class AuthService {
},
});

// Build magic link URL
// Build magic link URL for mail transport only. Do not log full token in production logs.
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
const magicUrl = `${baseUrl}/auth/verify?token=${token}`;

// Log for development (TODO: integrate actual email service)
console.log(`[MagicLink] Generated for ${email}: ${magicUrl}`);
logMagicLinkGenerated(email, tokenHash, expiresAt, magicUrl);

// TODO: Send email via nodemailer or your email service
// await sendEmail({ to: email, subject: 'Sign in to Mobius', body: magicUrl });
Expand Down
32 changes: 29 additions & 3 deletions src/lib/auth/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,33 @@

import { generateHMAC, generateToken } from '../crypto/hash';

const JWT_SECRET = process.env.JWT_SECRET || 'oaa-dev-secret-change-in-production';
const DEV_JWT_SECRET = 'oaa-dev-secret-change-in-production';
const MIN_JWT_SECRET_LENGTH = 32;
const JWT_EXPIRES_IN_DAYS = 30;
const JWT_EXPIRES_IN_MS = JWT_EXPIRES_IN_DAYS * 24 * 60 * 60 * 1000;

function resolveJWTSecret(): string {
const secret = process.env.JWT_SECRET?.trim();
const isProduction = process.env.NODE_ENV === 'production';

if (!secret) {
if (isProduction) {
throw new Error('JWT_SECRET is required in production');
}
return DEV_JWT_SECRET;
}

if (secret.length < MIN_JWT_SECRET_LENGTH) {
throw new Error(`JWT_SECRET must be at least ${MIN_JWT_SECRET_LENGTH} characters`);
}

if (isProduction && secret === DEV_JWT_SECRET) {
throw new Error('JWT_SECRET must not use the development fallback in production');
}

return secret;
}

export interface TokenPayload {
userId: string;
handle: string;
Expand Down Expand Up @@ -49,9 +72,10 @@ export function generateJWT(payload: Omit<TokenPayload, 'iat' | 'exp'>): string
exp: now + JWT_EXPIRES_IN_MS,
};

const secret = resolveJWTSecret();
const header = base64UrlEncode(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const body = base64UrlEncode(JSON.stringify(fullPayload));
const signature = base64UrlEncode(generateHMAC(`${header}.${body}`, JWT_SECRET));
const signature = base64UrlEncode(generateHMAC(`${header}.${body}`, secret));

return `${header}.${body}.${signature}`;
}
Expand All @@ -67,9 +91,11 @@ export function verifyJWT(token: string): TokenPayload | null {

const [header, body, signature] = parts;

const secret = resolveJWTSecret();

// Verify signature
const expectedSignature = base64UrlEncode(
generateHMAC(`${header}.${body}`, JWT_SECRET)
generateHMAC(`${header}.${body}`, secret)
);
if (signature !== expectedSignature) return null;

Expand Down
Loading