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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,27 @@ const app = issuer({
})
```

#### Turnstile (optional)

You can optionally require Cloudflare Turnstile for the built-in `password` and `code` providers.

```ts
import { PasswordProvider } from "@openauthjs/openauth/provider/password"
import { PasswordUI } from "@openauthjs/openauth/ui/password"

password: PasswordProvider(
PasswordUI({
sendCode: async (email, code) => {
console.log(email, code)
},
turnstile: {
siteKey: process.env.TURNSTILE_SITE_KEY!,
secretKey: process.env.TURNSTILE_SECRET_KEY!,
},
}),
),
```

Next up is the `subjects` field. Subjects are what the access token generated at the end of the auth flow will map to. Under the hood, the access token is a JWT that contains this data. You will likely just have a single subject to start but you can define additional ones for different types of users.

```ts
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@
"typescript": "5.6.3"
},
"private": true
}
}
31 changes: 31 additions & 0 deletions packages/openauth/src/provider/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@
import { Context } from "hono"
import { Provider } from "./provider.js"
import { generateUnbiasedDigits, timingSafeCompare } from "../random.js"
import {
getTurnstileToken,
TurnstileOptions,
verifyTurnstileToken,
} from "../turnstile.js"

export interface CodeProviderConfig<
Claims extends Record<string, string> = Record<string, string>,
Expand Down Expand Up @@ -96,6 +101,13 @@ export interface CodeProviderConfig<
* ```
*/
sendCode: (claims: Claims, code: string) => Promise<void | CodeProviderError>
/**
* Optionally enable Cloudflare Turnstile for the code request/resend actions.
*
* When enabled, the provider verifies the Turnstile token server-side and rejects requests
* with missing or invalid tokens.
*/
turnstile?: TurnstileOptions
}

/**
Expand Down Expand Up @@ -129,6 +141,9 @@ export type CodeProviderError =
| {
type: "invalid_code"
}
| {
type: "turnstile"
}
| {
type: "invalid_claim"
key: string
Expand All @@ -146,6 +161,20 @@ export function CodeProvider<
return {
type: "code",
init(routes, ctx) {
async function verifyTurnstile(req: Request, fd: FormData) {
if (!config.turnstile) return true
const token = getTurnstileToken(fd, config.turnstile.fieldName)
if (!token) return false
const result = await verifyTurnstileToken({
secretKey: config.turnstile.secretKey,
token,
req,
action: config.turnstile.action,
hostnames: config.turnstile.hostnames,
})
return result.success
}

async function transition(
c: Context,
next: CodeProviderState,
Expand Down Expand Up @@ -173,6 +202,8 @@ export function CodeProvider<
const action = fd.get("action")?.toString()

if (action === "request" || action === "resend") {
if (!(await verifyTurnstile(c.req.raw, fd)))
return transition(c, { type: "start" }, fd, { type: "turnstile" })
const claims = Object.fromEntries(fd) as Claims
delete claims.action
const err = await config.sendCode(claims, code)
Expand Down
43 changes: 43 additions & 0 deletions packages/openauth/src/provider/password.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ import { Storage } from "../storage/storage.js"
import { Provider } from "./provider.js"
import { generateUnbiasedDigits, timingSafeCompare } from "../random.js"
import { v1 } from "@standard-schema/spec"
import {
getTurnstileToken,
TurnstileOptions,
verifyTurnstileToken,
} from "../turnstile.js"

/**
* @internal
Expand Down Expand Up @@ -141,6 +146,13 @@ export interface PasswordConfig {
validatePassword?:
| v1.StandardSchema
| ((password: string) => Promise<string | undefined> | string | undefined)
/**
* Optionally enable Cloudflare Turnstile for sensitive actions.
*
* When enabled, the provider verifies the Turnstile token server-side and rejects requests
* with missing or invalid tokens.
*/
turnstile?: TurnstileOptions
}

/**
Expand Down Expand Up @@ -177,6 +189,9 @@ export type PasswordRegisterError =
| {
type: "invalid_code"
}
| {
type: "turnstile"
}
| {
type: "email_taken"
}
Expand Down Expand Up @@ -234,6 +249,9 @@ export type PasswordChangeError =
| {
type: "invalid_email"
}
| {
type: "turnstile"
}
| {
type: "invalid_code"
}
Expand Down Expand Up @@ -263,6 +281,9 @@ export type PasswordLoginError =
| {
type: "invalid_email"
}
| {
type: "turnstile"
}

export function PasswordProvider(
config: PasswordConfig,
Expand All @@ -274,6 +295,20 @@ export function PasswordProvider(
return {
type: "password",
init(routes, ctx) {
async function verifyTurnstile(req: Request, fd: FormData) {
if (!config.turnstile) return true
const token = getTurnstileToken(fd, config.turnstile.fieldName)
if (!token) return false
const result = await verifyTurnstileToken({
secretKey: config.turnstile.secretKey,
token,
req,
action: config.turnstile.action,
hostnames: config.turnstile.hostnames,
})
return result.success
}

routes.get("/authorize", async (c) =>
ctx.forward(c, await config.login(c.req.raw)),
)
Expand All @@ -283,6 +318,8 @@ export function PasswordProvider(
async function error(err: PasswordLoginError) {
return ctx.forward(c, await config.login(c.req.raw, fd, err))
}
if (!(await verifyTurnstile(c.req.raw, fd)))
return error({ type: "turnstile" })
const email = fd.get("email")?.toString()?.toLowerCase()
if (!email) return error({ type: "invalid_email" })
const hash = await Storage.get<HashedPassword>(ctx.storage, [
Expand Down Expand Up @@ -338,6 +375,8 @@ export function PasswordProvider(
}

if (action === "register" && provider.type === "start") {
if (!(await verifyTurnstile(c.req.raw, fd)))
return transition(provider, { type: "turnstile" })
const password = fd.get("password")?.toString()
const repeat = fd.get("repeat")?.toString()
if (!email) return transition(provider, { type: "invalid_email" })
Expand Down Expand Up @@ -387,6 +426,8 @@ export function PasswordProvider(
}

if (action === "register" && provider.type === "code") {
if (!(await verifyTurnstile(c.req.raw, fd)))
return transition(provider, { type: "turnstile" })
const code = generate()
await config.sendCode(provider.email, code)
return transition({
Expand Down Expand Up @@ -447,6 +488,8 @@ export function PasswordProvider(
}

if (action === "code") {
if (!(await verifyTurnstile(c.req.raw, fd)))
return transition(provider, { type: "turnstile" })
const email = fd.get("email")?.toString()?.toLowerCase()
if (!email)
return transition(
Expand Down
152 changes: 152 additions & 0 deletions packages/openauth/src/turnstile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
export const DEFAULT_TURNSTILE_FIELD_NAME = "cf-turnstile-response"

export interface TurnstileSiteVerifyResponse {
success: boolean
challenge_ts?: string
hostname?: string
action?: string
cdata?: string
"error-codes"?: string[]
}

export interface TurnstileOptions {
/**
* Cloudflare Turnstile secret key.
*/
secretKey: string
/**
* Cloudflare Turnstile site key (public key). Used by the built-in UI.
*/
siteKey?: string
/**
* The form field name containing the Turnstile token.
* @default "cf-turnstile-response"
*/
fieldName?: string
/**
* When set, require the `action` returned by Turnstile to match.
*/
action?: string
/**
* When set, require the `hostname` returned by Turnstile to match one of these.
*/
hostnames?: string[]
/**
* Optional widget options used by the built-in UI.
*/
widget?: {
theme?: "auto" | "light" | "dark"
size?: "normal" | "compact" | "invisible"
appearance?: "always" | "execute" | "interaction-only"
retry?: "auto" | "never"
refreshExpired?: "auto" | "manual" | "never"
}
}

export function getTurnstileToken(
form: FormData,
fieldName: string = DEFAULT_TURNSTILE_FIELD_NAME,
) {
const token = form.get(fieldName)?.toString()
return token ? token : undefined
}

function getClientIP(req: Request) {
const from = (value: string | null) => value?.split(",")[0]?.trim()
return (
from(req.headers.get("cf-connecting-ip")) ||
from(req.headers.get("x-forwarded-for")) ||
from(req.headers.get("x-real-ip")) ||
undefined
)
}

function getRequestHostname(req: Request) {
const forwardedHost = req.headers.get("x-forwarded-host")
if (forwardedHost) {
const raw = forwardedHost.split(",")[0]?.trim()
if (!raw) return undefined
try {
return new URL(`https://${raw}`).hostname
} catch {
return raw.split(":")[0]
}
}
try {
return new URL(req.url).hostname
} catch {
return undefined
}
}

export type TurnstileVerifyResult = {
success: boolean
response?: TurnstileSiteVerifyResponse
errorCodes?: string[]
}

/**
* Verifies a Cloudflare Turnstile token server-side. Fails closed.
*/
export async function verifyTurnstileToken(opts: {
secretKey: string
token: string
req?: Request
action?: string
hostnames?: string[]
}): Promise<TurnstileVerifyResult> {
const body = new URLSearchParams({
secret: opts.secretKey,
response: opts.token,
})

const clientIP = opts.req ? getClientIP(opts.req) : undefined
if (clientIP) body.set("remoteip", clientIP)

let json: TurnstileSiteVerifyResponse
try {
const res = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body,
},
)
json = (await res.json()) as TurnstileSiteVerifyResponse
} catch (err) {
return {
success: false,
errorCodes: ["siteverify_failed"],
}
}

if (!json?.success) {
return {
success: false,
response: json,
errorCodes: json?.["error-codes"] ?? ["invalid_token"],
}
}

if (opts.action && json.action !== opts.action) {
return {
success: false,
response: json,
errorCodes: ["action_mismatch"],
}
}

if (opts.hostnames?.length) {
const hostname = json.hostname
if (!hostname || !opts.hostnames.includes(hostname)) {
return {
success: false,
response: json,
errorCodes: ["hostname_mismatch"],
}
}
}

return { success: true, response: json }
}
Loading