Skip to content

Commit 96b1385

Browse files
Cut over: the CMS is the identity provider; Auth0 is gone
The atomic step. The frontend authenticates against the CMS (/api/token/, NextAuth credentials with silent refresh) instead of Auth0, the backend's AUTH_* env now points at the CMS's JWKS, and every Auth0 identifier and secret leaves infra. Splitting these apart would open a window where nobody can log in, so they land together — one 'pulumi up' on the single stack flips both sides. Also removes what the CMS replaced: the legacy backend cms module (its tags/places content endpoints and models), keeping only the under-construction site_settings pair the frontend proxy still reads, and the last legacy-model references in alembic's env.py. ROLLBACK: revert jwtAudience/cmsDomain to their Auth0 values and pin the previous frontend image with the frontendImageTag config override, then 'pulumi up'. BEFORE MERGING — none of this is covered by CI, which runs no frontend tests at all: log in and out, cross the access-token expiry to prove silent refresh, race two tabs, confirm a cross-site GET to /auth/logout does not sign you out, and confirm ?returnTo=//evil.com falls back to /. Provision users in the CMS first ('manage.py provision_users'), and run migrate_tiptap with its --owners mapping before or during this deploy.
1 parent c030ee4 commit 96b1385

27 files changed

Lines changed: 428 additions & 1060 deletions

.github/workflows/deploy-app.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,12 @@ jobs:
4242
if [ "${{ github.ref_name }}" = "dev" ]; then
4343
echo "stack=dev" >> "$GITHUB_OUTPUT"
4444
echo "api_url=${{ vars.API_URL_DEV }}" >> "$GITHUB_OUTPUT"
45+
echo "cms_url=${{ vars.CMS_URL_DEV }}" >> "$GITHUB_OUTPUT"
4546
echo "environment=development" >> "$GITHUB_OUTPUT"
4647
else
4748
echo "stack=prod" >> "$GITHUB_OUTPUT"
4849
echo "api_url=${{ vars.API_URL_PROD }}" >> "$GITHUB_OUTPUT"
50+
echo "cms_url=${{ vars.CMS_URL_PROD }}" >> "$GITHUB_OUTPUT"
4951
echo "environment=production" >> "$GITHUB_OUTPUT"
5052
fi
5153
# NEXT_PUBLIC_* values are baked into the bundle at build time; this
@@ -63,6 +65,7 @@ jobs:
6365
NEXT_PUBLIC_S3_BUCKET_URL_MIRROR1=https://tilesets2.cdn.districtr.org
6466
NEXT_PUBLIC_S3_BUCKET_URL_MIRROR2=https://tilesets3.cdn.districtr.org
6567
NEXT_PUBLIC_API_URL=${{ steps.cfg.outputs.api_url }}
68+
NEXT_PUBLIC_CMS_URL=${{ steps.cfg.outputs.cms_url }}
6669
NEXT_PUBLIC_ENVIRONMENT=${{ steps.cfg.outputs.environment }}
6770
NEXT_PUBLIC_BUILD_TAG=${{ github.sha }}
6871
EOF

.github/workflows/preview.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ env:
4747
AWS_REGION: ${{ vars.AWS_REGION || 'us-east-2' }}
4848
CLUSTER: districtr-dev
4949
DEV_API_URL: https://api.dev.districtr.org
50+
# Previews share the dev CMS (a preview does not get its own CMS service),
51+
# so editor login and content fetches resolve there.
52+
DEV_CMS_URL: https://cms.dev.districtr.org
5053

5154
jobs:
5255
preview:
@@ -341,6 +344,7 @@ jobs:
341344
FE_HOST: ${{ steps.setup.outputs.fe_host }}
342345
FE_URL: ${{ steps.setup.outputs.fe_url }}
343346
API_URL: ${{ steps.setup.outputs.api_url }}
347+
CMS_URL: ${{ env.DEV_CMS_URL }}
344348
FE_TG: ${{ steps.setup.outputs.fe_tg }}
345349
FE_SERVICE: ${{ steps.setup.outputs.fe_service }}
346350
FE_PRIORITY: ${{ steps.setup.outputs.fe_priority }}
@@ -370,6 +374,7 @@ jobs:
370374
NEXT_PUBLIC_S3_BUCKET_URL_MIRROR1=https://tilesets2.cdn.districtr.org
371375
NEXT_PUBLIC_S3_BUCKET_URL_MIRROR2=https://tilesets3.cdn.districtr.org
372376
NEXT_PUBLIC_API_URL=${API_URL}
377+
NEXT_PUBLIC_CMS_URL=${CMS_URL}
373378
NEXT_PUBLIC_BUILD_TAG=${GITHUB_SHA}
374379
EOF
375380
docker build --secret id=SENTRY_AUTH_TOKEN,env=SENTRY_AUTH_TOKEN -t "$IMAGE" app

app/.env.docker.example

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,10 @@ NEXT_PUBLIC_S3_BUCKET_URL_MIRROR1=https://fill-me.s3.amazonaws.com
55
NEXT_PUBLIC_S3_BUCKET_URL_MIRROR2=https://fill-me.s3.amazonaws.com
66
NEXT_SERVER_API_URL=http://backend:8000
77

8-
#Auth0
9-
AUTH0_CLIENT_ID='fill-me'
10-
AUTH0_CLIENT_SECRET='fill-me'
11-
AUTH0_DOMAIN=fill-me.us.auth0.com # just the domain, without https://
12-
AUTH0_ISSUER=https://fill-me.us.auth0.com/ # full url with https://
13-
AUTH0_AUDIENCE=http://localhost:8000/
14-
APP_BASE_URL=http://localhost:3000
15-
AUTH0_ALGORITHMS=RS256
16-
AUTH0_SECRET="fill-me" # Added by `npx auth`. Read more: https://cli.authjs.dev
8+
# Auth (Auth.js credentials flow against the Django CMS)
9+
CMS_URL=http://cms:8000 # server-side CMS token endpoint URL (from the frontend container)
10+
NEXT_PUBLIC_CMS_URL=http://localhost:8001 # browser-facing CMS URL
11+
AUTH_SECRET="fill-me" # Generate with: openssl rand -base64 32
1712

1813
# Feedback form
1914
NEXT_PUBLIC_FEEDBACK_FORM="fill-me-optionally"

app/.env.production

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,7 @@ NEXT_PUBLIC_API_URL=https://districtr-v2-api.fly.dev
22
NEXT_PUBLIC_ENVIRONMENT=production
33
NEXT_PUBLIC_S3_BUCKET_URL=https://tilesets1.cdn.districtr.org
44

5-
AUTH0_CLIENT_ID=foo
6-
AUTH0_CLIENT_SECRET=bar
7-
AUTH0_DOMAIN=https://dev-1234567.us.auth0.com/
8-
AUTH0_ORGANIZATION=org_123456
9-
AUTH0_AUDIENCE=server-url
10-
AUTH0_SECRET=wwwwwwww
11-
APP_BASE_URL=http://localhost:3000
5+
# Auth (CMS issuer) — real values come from Fly secrets at runtime
6+
CMS_URL=https://districtr-v2-cms.fly.dev
7+
NEXT_PUBLIC_CMS_URL=https://districtr-v2-cms.fly.dev
8+
AUTH_SECRET=fill-me
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import {handlers} from '@/auth';
2+
3+
export const {GET, POST} = handlers;

app/src/app/auth/login/actions.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
'use server';
2+
3+
import {AuthError} from 'next-auth';
4+
import {redirect} from 'next/navigation';
5+
import {signIn} from '@/auth';
6+
import {sanitizeReturnTo} from '@/app/utils/sanitizeReturnTo';
7+
8+
export async function loginAction(formData: FormData): Promise<void> {
9+
const returnTo = sanitizeReturnTo(formData.get('returnTo'));
10+
try {
11+
await signIn('credentials', {
12+
email: formData.get('email'),
13+
password: formData.get('password'),
14+
redirectTo: returnTo,
15+
});
16+
} catch (error) {
17+
if (error instanceof AuthError) {
18+
// Bad credentials — bounce back to the login page with an error flag
19+
redirect(`/auth/login?error=CredentialsSignin&returnTo=${encodeURIComponent(returnTo)}`);
20+
}
21+
// Success: signIn throws a NEXT_REDIRECT to returnTo — let it propagate
22+
throw error;
23+
}
24+
}

app/src/app/auth/login/page.tsx

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type {Metadata} from 'next';
2+
import {redirect} from 'next/navigation';
3+
import {Box, Button, Card, Flex, Heading, Text, TextField} from '@radix-ui/themes';
4+
import {getServerSession} from '@/app/lib/auth';
5+
import {sanitizeReturnTo} from '@/app/utils/sanitizeReturnTo';
6+
import {loginAction} from './actions';
7+
8+
export const metadata: Metadata = {
9+
title: 'Sign in - Districtr',
10+
description: 'Sign in to Districtr',
11+
};
12+
13+
export default async function LoginPage({
14+
searchParams,
15+
}: {
16+
searchParams: Promise<{returnTo?: string; error?: string}>;
17+
}) {
18+
const params = await searchParams;
19+
const returnTo = sanitizeReturnTo(params.returnTo);
20+
21+
// Already signed in — go straight to the destination
22+
const session = await getServerSession();
23+
if (session?.user) {
24+
redirect(returnTo);
25+
}
26+
27+
return (
28+
<Flex align="center" justify="center" className="min-h-screen bg-gray-100">
29+
<Card size="3" className="w-full max-w-md">
30+
<Flex direction="column" gap="4" p="4">
31+
<Heading size="5">Sign in to Districtr</Heading>
32+
{params.error && (
33+
<Text size="2" color="red">
34+
Invalid email or password. Please try again.
35+
</Text>
36+
)}
37+
<form action={loginAction}>
38+
<Flex direction="column" gap="3">
39+
<input type="hidden" name="returnTo" value={returnTo} />
40+
<Box>
41+
<Text as="label" size="2" weight="medium" htmlFor="email">
42+
Email
43+
</Text>
44+
<TextField.Root
45+
id="email"
46+
name="email"
47+
type="email"
48+
placeholder="you@example.com"
49+
autoComplete="username"
50+
required
51+
/>
52+
</Box>
53+
<Box>
54+
<Text as="label" size="2" weight="medium" htmlFor="password">
55+
Password
56+
</Text>
57+
<TextField.Root
58+
id="password"
59+
name="password"
60+
type="password"
61+
placeholder="Password"
62+
autoComplete="current-password"
63+
required
64+
/>
65+
</Box>
66+
<Button type="submit" size="3">
67+
Sign in
68+
</Button>
69+
</Flex>
70+
</form>
71+
</Flex>
72+
</Card>
73+
</Flex>
74+
);
75+
}

app/src/app/auth/logout/route.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import {signOut} from '@/auth';
2+
import {NextResponse} from 'next/server';
3+
4+
/**
5+
* Preserves the pre-Auth.js URL contract: the app signs users out by
6+
* navigating to GET /auth/logout.
7+
*
8+
* CSRF guard: a GET logout can be triggered cross-site — a top-level <a> click
9+
* or scripted redirect on a malicious page would log the user out against their
10+
* will. SameSite=Lax already blocks subresource drive-bys (e.g. <img>), but NOT
11+
* a cross-site top-level navigation. We use the Fetch Metadata `Sec-Fetch-Site`
12+
* request header to allow only first-party requests (same-origin / same-site)
13+
* and direct user navigation (typed URL or bookmark report "none"); an explicit
14+
* "cross-site" request bounces home WITHOUT signing out. Browsers too old to
15+
* send the header fall through to the prior behavior.
16+
*/
17+
export async function GET(request: Request) {
18+
if (request.headers.get('sec-fetch-site') === 'cross-site') {
19+
return NextResponse.redirect(new URL('/', request.url));
20+
}
21+
await signOut({redirectTo: '/'});
22+
}

app/src/app/auth/token/route.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import {getServerSession} from '@/app/lib/auth';
2+
3+
/**
4+
* Returns the current client session (fresh access token included) for client
5+
* polling. getServerSession -> auth() runs the NextAuth jwt callback, which
6+
* silently refreshes an expiring access token — and unlike server components,
7+
* route handlers CAN write cookies, so the rotated refresh token is persisted
8+
* back to the session cookie here.
9+
*
10+
* Responds with JSON `null` when unauthenticated or the refresh failed.
11+
*/
12+
export const dynamic = 'force-dynamic';
13+
14+
export async function GET() {
15+
const session = await getServerSession();
16+
return Response.json(session, {headers: {'Cache-Control': 'no-store'}});
17+
}

app/src/app/lib/auth.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {auth} from '@/auth';
2+
3+
export type SessionUser = {
4+
email?: string | null;
5+
name?: string | null;
6+
roles?: string[];
7+
};
8+
9+
/**
10+
* Serializable session shape passed from server components to the client.
11+
* Mirrors the shape previously provided by @auth0/nextjs-auth0.
12+
*/
13+
export type ClientSession = {
14+
user?: SessionUser;
15+
tokenSet?: {
16+
accessToken: string;
17+
};
18+
};
19+
20+
/**
21+
* Server-side session helper. Returns null when unauthenticated or when the
22+
* silent token refresh has failed (forcing a re-login).
23+
*/
24+
export const getServerSession = async (): Promise<ClientSession | null> => {
25+
const session = await auth();
26+
if (!session?.user || session.error === 'RefreshTokenError') {
27+
return null;
28+
}
29+
return {
30+
user: {
31+
email: session.user.email,
32+
name: session.user.name,
33+
roles: session.user.roles ?? [],
34+
},
35+
tokenSet: session.accessToken ? {accessToken: session.accessToken} : undefined,
36+
};
37+
};

0 commit comments

Comments
 (0)