Skip to content
Open
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
24 changes: 24 additions & 0 deletions CONCEPTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,30 @@

Shared domain vocabulary for this project — entities, named processes, and status concepts with project-specific meaning. Seeded with core domain vocabulary, then accretes as ce-compound and ce-compound-refresh process learnings; direct edits are fine. Glossary only, not a spec or catch-all.

## Application access

### Registered Application

A Jesus Film product or service recognized by Auth as an application-access boundary, with its own ownership, trust posture, lifecycle, deployment environments, grants, and issued tokens.

### Application Environment

A deployment-specific authorization boundary within a Registered Application that carries the OAuth client posture and approval state against which grants and tokens are evaluated.

### Application Grant

An explicit, revocable approval that gives a user or service a set of scopes for one Registered Application and Application Environment; an OAuth client's allowed scopes do not constitute an Application Grant.

### Dynamic MCP Client

A public OAuth client created at runtime by an MCP host so that each host can establish its own callback metadata and client identity without a pre-seeded credential.

Registering a Dynamic MCP Client identifies the client but grants no application access; authorization still depends on an applicable Application Grant, and the companion MCP resource implementation independently enforces the issued token.

## Relationships

A Registered Application contains Application Environments. Application Grants and issued tokens target an Application Environment, while a Dynamic MCP Client requests access to the protected resource associated with that environment.

## Devotional generation

### Devotional Workspace
Expand Down
141 changes: 141 additions & 0 deletions apps/auth/src/app/api/auth/[...all]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,147 @@ describe("Auth route wrapper", () => {
vi.unstubAllEnvs()
})

it("normalizes implicit web loopback DCR clients to the native application type", async () => {
authPost.mockResolvedValueOnce(
Response.json({ client_id: "claude_dynamic" }),
)
const { POST } = await import("./route")
const response = await POST(
new Request("http://localhost:3004/api/auth/oauth2/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
client_name: "Claude Code",
redirect_uris: ["http://localhost:3118/callback"],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
}),
}),
{ params: Promise.resolve({ all: ["oauth2", "register"] }) },
)

expect(response.status).toBe(200)
const forwarded = authPost.mock.calls[0]?.[0] as Request
await expect(forwarded.json()).resolves.toMatchObject({
application_type: "native",
redirect_uris: ["http://localhost:3118/callback"],
token_endpoint_auth_method: "none",
})
})

it.each([
{
name: "an explicit web client",
body: {
application_type: "web",
redirect_uris: ["http://localhost:3118/callback"],
},
},
{
name: "an explicit native client",
body: {
application_type: "native",
redirect_uris: ["http://localhost:3118/callback"],
},
},
{
name: "an explicit confidential client",
body: {
redirect_uris: ["http://localhost:3118/callback"],
token_endpoint_auth_method: "client_secret_basic",
},
},
{
name: "a public HTTP redirect",
body: { redirect_uris: ["http://example.com/callback"] },
},
{
name: "mixed loopback and public redirects",
body: {
redirect_uris: [
"http://127.0.0.1:3118/callback",
"https://example.com/callback",
],
},
},
{
name: "an empty redirect list",
body: { redirect_uris: [] },
},
])("does not normalize $name", async ({ body }) => {
authPost.mockResolvedValueOnce(Response.json({ client_id: "dynamic" }))
const { POST } = await import("./route")
await POST(
new Request("http://localhost:3004/api/auth/oauth2/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
{ params: Promise.resolve({ all: ["oauth2", "register"] }) },
)

const forwarded = authPost.mock.calls[0]?.[0] as Request
await expect(forwarded.json()).resolves.toEqual(body)
})

it.each(["http://127.0.0.1:49173/callback", "http://[::1]:49173/callback"])(
"normalizes implicit loopback redirect %s",
async (redirectUri) => {
authPost.mockResolvedValueOnce(Response.json({ client_id: "dynamic" }))
const { POST } = await import("./route")
await POST(
new Request("http://localhost:3004/api/auth/oauth2/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ redirect_uris: [redirectUri] }),
}),
{ params: Promise.resolve({ all: ["oauth2", "register"] }) },
)

const forwarded = authPost.mock.calls[0]?.[0] as Request
await expect(forwarded.json()).resolves.toEqual({
application_type: "native",
redirect_uris: [redirectUri],
token_endpoint_auth_method: "none",
})
},
)

it("rejects oversized DCR registration bodies case-insensitively", async () => {
const { POST } = await import("./route")
const response = await POST(
new Request("http://localhost:3004/api/auth/oauth2/register", {
method: "POST",
headers: { "content-type": "Application/JSON" },
body: JSON.stringify({ padding: "x".repeat(64 * 1024) }),
}),
{ params: Promise.resolve({ all: ["oauth2", "register"] }) },
)

expect(response.status).toBe(413)
expect(authPost).not.toHaveBeenCalled()
})

it("preserves malformed DCR JSON for the provider", async () => {
authPost.mockResolvedValueOnce(Response.json({ error: "invalid_request" }))
const { POST } = await import("./route")
await POST(
new Request("http://localhost:3004/api/auth/oauth2/register", {
method: "POST",
headers: {
"content-length": "1",
"content-type": "application/json",
},
body: "{",
}),
{ params: Promise.resolve({ all: ["oauth2", "register"] }) },
)

const forwarded = authPost.mock.calls[0]?.[0] as Request
expect(forwarded.headers.has("content-length")).toBe(false)
await expect(forwarded.text()).resolves.toBe("{")
})

it("downscopes an authenticated Changelog authorize request before the provider sees it", async () => {
getSession.mockResolvedValueOnce({
user: { id: "user_123", membershipStatus: "ACTIVE" },
Expand Down
113 changes: 113 additions & 0 deletions apps/auth/src/app/api/auth/[...all]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const WINDOW_MS = 60_000
const MAX_ATTEMPTS = 10
const LAST_LOGIN_METHOD_COOKIE = "forge_auth_last_login_method"
const LAST_LOGIN_METHOD_MAX_AGE = 60 * 60 * 24 * 365
const MAX_DCR_BODY_BYTES = 64 * 1024

type LastLoginMethod = "apple" | "email" | "facebook" | "google" | "okta"
const providerPriority = ["google", "facebook", "apple", "okta"] as const
Expand All @@ -51,6 +52,113 @@ function isFormPostRequest(request: Request): boolean {
)
}

function isHttpLoopbackRedirect(uri: string): boolean {
try {
const url = new URL(uri)
return (
url.protocol === "http:" &&
(url.hostname === "localhost" ||
url.hostname === "127.0.0.1" ||
url.hostname === "[::1]")
)
} catch {
return false
}
}

async function normalizeLoopbackDcrRequest(
request: Request,
): Promise<Request | Response> {
if (
!request.headers
.get("content-type")
?.toLowerCase()
.includes("application/json")
) {
return request
}

const bodyBytes = await readBoundedBody(request, MAX_DCR_BODY_BYTES)
if (!bodyBytes) {
return Response.json(
{ error: "Request body is too large" },
{ status: 413 },
)
}
const headers = new Headers(request.headers)
headers.delete("content-length")
const forward = (body: BodyInit) =>
new Request(request.url, {
body,
headers,
method: request.method,
signal: request.signal,
})

let parsed: unknown
try {
parsed = JSON.parse(new TextDecoder().decode(bodyBytes))
} catch {
return forward(bodyBytes)
}
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
return forward(bodyBytes)
}
const body = parsed as Record<string, unknown>
const redirectUris = body.redirect_uris
if (
body.application_type !== undefined ||
(body.token_endpoint_auth_method !== undefined &&
body.token_endpoint_auth_method !== "none") ||
!Array.isArray(redirectUris) ||
redirectUris.length === 0 ||
!redirectUris.every(
(uri): uri is string =>
typeof uri === "string" && isHttpLoopbackRedirect(uri),
)
) {
return forward(bodyBytes)
}

return new Request(request.url, {
body: JSON.stringify({
...body,
application_type: "native",
token_endpoint_auth_method: "none",
}),
headers,
method: request.method,
signal: request.signal,
})
}

async function readBoundedBody(
request: Request,
maxBytes: number,
): Promise<ArrayBuffer | undefined> {
const reader = request.body?.getReader()
if (!reader) return new ArrayBuffer(0)
const chunks: Uint8Array[] = []
let length = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
length += value.byteLength
if (length > maxBytes) {
await reader.cancel().catch(() => undefined)
return
}
chunks.push(value)
}
const bytes = new Uint8Array(length)
let offset = 0
for (const chunk of chunks) {
bytes.set(chunk, offset)
offset += chunk.byteLength
}
return bytes.buffer
}

function sha256(input: string): string {
return createHash("sha256").update(input).digest("hex")
}
Expand Down Expand Up @@ -1052,6 +1160,11 @@ export async function POST(
const policyResponse = await enforceChangelogConsentPolicy(request)
if (policyResponse) return policyResponse
}
if (path === "oauth2/register") {
const normalized = await normalizeLoopbackDcrRequest(request)
if (normalized instanceof Response) return normalized
request = normalized
}
if (isDeviceGrantPath(path)) {
return withNoStore(await authRouteHandlers.POST(request))
}
Expand Down
8 changes: 4 additions & 4 deletions docs/roadmap/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ Build trusted, scalable AI capabilities that help people discover gospel content
## Status (August 24, 2026)

- **Total tickets:** 588
- **Complete:** 431
- **Complete:** 432
- **In progress:** 40
- **Not started:** 37
- **Blocked:** 80
- **Overdue and not complete:** 107
- **Blocked:** 79
- **Overdue and not complete:** 106

## Feature Index

Expand Down Expand Up @@ -276,7 +276,7 @@ Build trusted, scalable AI capabilities that help people discover gospel content
| [feat-327](platform/feat-327-admin-prisma-adapter-pool-config.md) | Admin Prisma adapter pool config | codex | P0 | 2026-08-03 | 1 | 2026-08-03 | complete |
| [feat-352](platform/feat-352-mastra-seo-live-proposal-digest.md) | Align live SEO proposal digest with Admin persistence | codex | P0 | 2026-08-11 | 1 | 2026-08-11 | complete |
| [feat-356](platform/feat-356-preserve-iso-dates-in-seo-report-redaction.md) | Preserve ISO dates in SEO report redaction | codex | P0 | 2026-08-11 | 1 | 2026-08-11 | complete |
| [feat-399](platform/feat-399-changelog-first-party-auth.md) | Register Changelog with first-party Auth grants | edmonday | P0 | 2026-08-19 | 3 | 2026-08-21 | blocked |
| [feat-399](platform/feat-399-changelog-first-party-auth.md) | Register Changelog with first-party Auth grants | edmonday | P0 | 2026-08-19 | 3 | 2026-08-21 | complete |
| [feat-401](platform/feat-401-better-auth-native-resource-upgrade.md) | Upgrade Better Auth for native resource binding | edmonday | P0 | 2026-08-20 | 4 | 2026-08-23 | complete |
| [feat-402](platform/feat-402-mobile-expo-sdk57-patch-alignment.md) | Mobile Expo SDK 57 patch alignment | edmonday | P0 | 2026-08-21 | 1 | 2026-08-21 | complete |
| [feat-278](platform/feat-278-watch-russian-authored-content-localization.md) | Watch Russian authored content localization | unassigned | P1 | — | 2 | — | not-started |
Expand Down
Loading
Loading