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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
DATABASE_URL=postgresql://user:password@host/database?sslmode=require
BETTER_AUTH_SECRET=your-secret-key-here
BETTER_AUTH_URL=http://localhost:3000
TRUSTED_ORIGINS=http://localhost:5173
CORS_ORIGIN=http://localhost:5173
RESEND_API_KEY=re_xxxxxxxxx
EMAIL_FROM_ADDRESS=no-reply@example.com
EMAIL_FROM_NAME=tascal
EMAIL_SEND_TIMEOUT_MS=3000
PASSWORD_RESET_MIN_RESPONSE_MS=3000
6 changes: 4 additions & 2 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,10 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max

# Cloud Run の環境変数(DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL, TRUSTED_ORIGINS)は
# Google Cloud コンソールまたは gcloud CLI で事前に設定してください
# Cloud Run の環境変数(DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL,
# TRUSTED_ORIGINS, EMAIL_FROM_ADDRESS, EMAIL_FROM_NAME)は Google Cloud
# コンソールまたは gcloud CLI で事前に設定してください。
# RESEND_API_KEY は Secret Manager から Cloud Run に割り当てます。
# GitHub Secrets にも DATABASE_URL の設定が必要です(マイグレーション実行用)
- name: Deploy to Cloud Run
run: |
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ jobs:

- name: Deploy to Cloud Run
run: |
# PR preview には意図的に RESEND_API_KEY / EMAIL_FROM_* を渡さない。
# 未レビューコードからメール送信資格情報を隔離する。
IMAGE=${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.SERVICE_NAME }}:${{ github.sha }}
gcloud run deploy ${{ env.SERVICE_NAME }} \
--image $IMAGE \
Expand Down
6 changes: 6 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
DATABASE_URL=postgresql://tascal:tascal@localhost:5432/tascal
BETTER_AUTH_SECRET=your-secret-key-here
BETTER_AUTH_URL=http://localhost:3000
TRUSTED_ORIGINS=http://localhost:5173
CORS_ORIGIN=http://localhost:5173
RESEND_API_KEY=re_xxxxxxxxx
EMAIL_FROM_ADDRESS=no-reply@example.com
EMAIL_FROM_NAME=tascal
EMAIL_SEND_TIMEOUT_MS=3000
PASSWORD_RESET_MIN_RESPONSE_MS=3000
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"hono": "^4.12.34",
"pg": "^8.20.0",
"pino": "^10.3.1",
"resend": "^6.20.0",
"zod": "^4.3.6"
},
"devDependencies": {
Expand Down
25 changes: 23 additions & 2 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { HTTPException } from "hono/http-exception";
import { cors } from "hono/cors";
import { getAuth } from "./auth.js";
import type { Auth } from "./auth.js";
import { handleAuthRequest } from "./auth-handler.js";
import { getDb } from "./db/index.js";
import logger from "./logger.js";
import categoriesApp from "./routes/categories.js";
Expand All @@ -14,12 +15,20 @@ import usersApp from "./routes/users.js";
type AuthVariables = {
user: Auth["$Infer"]["Session"]["user"] | null;
session: Auth["$Infer"]["Session"]["session"] | null;
requestId: string;
};

const SKIP_LOG_PATHS = new Set(["/healthz"]);

const app = new Hono<{ Variables: AuthVariables }>();

app.use("*", async (c, next) => {
const requestId = crypto.randomUUID();
c.set("requestId", requestId);
c.header("X-Request-ID", requestId);
await next();
});

// リクエストログミドルウェア
app.use("*", async (c, next) => {
if (SKIP_LOG_PATHS.has(c.req.path)) {
Expand All @@ -41,6 +50,7 @@ app.use("*", async (c, next) => {
path: c.req.path,
status,
duration,
requestId: c.get("requestId"),
},
`${c.req.method} ${c.req.path} ${status} ${duration}ms`,
);
Expand All @@ -55,6 +65,7 @@ app.onError((err, c) => {
method: c.req.method,
path: c.req.path,
status: err.status,
requestId: c.get("requestId"),
},
`HTTP error: ${err.message}`,
);
Expand All @@ -66,6 +77,7 @@ app.onError((err, c) => {
err,
method: c.req.method,
path: c.req.path,
requestId: c.get("requestId"),
},
`Unhandled error: ${err.message}`,
);
Expand Down Expand Up @@ -111,8 +123,17 @@ app.use("/api/*", async (c, next) => {
await next();
});

app.on(["POST", "GET"], "/api/auth/**", (c) => {
return getAuth().handler(c.req.raw);
app.on(["POST", "GET"], "/api/auth/**", async (c) => {
const configuredMinimum = Number(process.env.PASSWORD_RESET_MIN_RESPONSE_MS);
return handleAuthRequest({
request: c.req.raw,
requestId: c.get("requestId"),
handler: (request) => getAuth().handler(request),
passwordResetMinimumMs:
Number.isFinite(configuredMinimum) && configuredMinimum >= 0
? configuredMinimum
: undefined,
});
});

// RPC 型推論のためチェイン形式でルートをマウント
Expand Down
215 changes: 215 additions & 0 deletions apps/api/src/auth-email.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { describe, expect, it } from "vitest";
import { getTestInstance } from "better-auth/test";
import { createAuthOptions } from "./auth.js";
import type { TransactionalEmail, TransactionalEmailSender } from "./email.js";

function createCollectingSender() {
const messages: TransactionalEmail[] = [];
const sender: TransactionalEmailSender = {
send(message) {
messages.push(message);
return Promise.resolve();
},
};
return { messages, sender };
}

async function postJson(
instance: {
customFetchImpl: (url: string, init?: RequestInit) => Promise<Response>;
},
path: string,
body: unknown,
) {
return instance.customFetchImpl(`http://localhost:3000/api/auth${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Origin: "http://localhost:3000",
},
body: JSON.stringify(body),
});
}

describe("email verification", () => {
it("新規登録では session を作らず、確認後にだけログインでき、再送も sign-in に限定する", async () => {
const { messages, sender } = createCollectingSender();
const instance = await getTestInstance(createAuthOptions(sender));
messages.length = 0;
const email = "new-user@example.com";
const password = "password123";

const signUp = await postJson(instance, "/sign-up/email", {
name: "New User",
email,
password,
});
expect(signUp.status).toBe(200);
expect(await signUp.json()).toMatchObject({ token: null });
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
purpose: "email_verification",
to: email,
});

const blockedSignIn = await postJson(instance, "/sign-in/email", {
email,
password,
});
expect(blockedSignIn.status).toBe(403);
expect(await blockedSignIn.json()).toMatchObject({
code: "EMAIL_NOT_VERIFIED",
});
expect(messages).toHaveLength(2);

const publicResend = await postJson(instance, "/send-verification-email", {
email,
callbackURL: "/login?verified=true",
});
expect(publicResend.status).toBe(404);

const verify = await instance.customFetchImpl(
messages[1].text.match(/https?:\/\/\S+/)![0],
{
redirect: "manual",
},
);
expect(verify.status).toBe(302);
expect(verify.headers.get("location")).toContain("/login?verified=true");

const signedIn = await postJson(instance, "/sign-in/email", {
email,
password,
});
expect(signedIn.status).toBe(200);
const signedInBody = (await signedIn.json()) as { token?: unknown };
expect(typeof signedInBody.token).toBe("string");
});

it("不正・期限切れの確認 token を処理せず callback にエラーを返す", async () => {
const { messages, sender } = createCollectingSender();
const options = createAuthOptions(sender);
const instance = await getTestInstance({
...options,
emailVerification: { ...options.emailVerification, expiresIn: -1 },
});
messages.length = 0;

await postJson(instance, "/sign-up/email", {
name: "Expired User",
email: "expired@example.com",
password: "password123",
callbackURL: "/login?verified=true",
});
const expired = await instance.customFetchImpl(
messages[0].text.match(/https?:\/\/\S+/)![0],
{ redirect: "manual" },
);
expect(expired.status).toBe(302);
expect(expired.headers.get("location")).toContain("error=TOKEN_EXPIRED");

const invalid = await instance.customFetchImpl(
"http://localhost:3000/api/auth/verify-email?token=invalid&callbackURL=%2Flogin%3Fverified%3Dtrue",
{ redirect: "manual" },
);
expect(invalid.status).toBe(302);
expect(invalid.headers.get("location")).toContain("error=INVALID_TOKEN");
});
});

describe("password reset", () => {
it("登録有無を同じ応答にし、再設定後は既存 session を失効して新 password だけを受け付ける", async () => {
const { messages, sender } = createCollectingSender();
const instance = await getTestInstance(createAuthOptions(sender));
messages.length = 0;
const email = "reset-user@example.com";
const oldPassword = "password123";
const newPassword = "new-password123";

await postJson(instance, "/sign-up/email", {
name: "Reset User",
email,
password: oldPassword,
callbackURL: "/login?verified=true",
});
await instance.customFetchImpl(
messages[0].text.match(/https?:\/\/\S+/)![0],
{
redirect: "manual",
},
);

const signIn = await postJson(instance, "/sign-in/email", {
email,
password: oldPassword,
});
const cookie = signIn.headers.get("set-cookie")!;

const known = await postJson(instance, "/request-password-reset", {
email,
redirectTo: "/reset-password",
});
const unknown = await postJson(instance, "/request-password-reset", {
email: "unknown@example.com",
redirectTo: "/reset-password",
});
expect(await known.json()).toEqual(await unknown.json());

const resetMessage = messages.find(
(message) => message.purpose === "password_reset",
)!;
const callback = await instance.customFetchImpl(
resetMessage.text.match(/https?:\/\/\S+/)![0],
{ redirect: "manual" },
);
const location = callback.headers.get("location")!;
const token = new URL(location, "http://localhost:3000").searchParams.get(
"token",
)!;

const reset = await postJson(instance, "/reset-password", {
token,
newPassword,
});
expect(reset.status).toBe(200);

const oldSession = await instance.customFetchImpl(
"http://localhost:3000/api/auth/get-session",
{ headers: { Cookie: cookie } },
);
expect(await oldSession.json()).toBeNull();
expect(
(
await postJson(instance, "/sign-in/email", {
email,
password: oldPassword,
})
).status,
).toBe(401);
expect(
(
await postJson(instance, "/sign-in/email", {
email,
password: newPassword,
})
).status,
).toBe(200);

expect(
(
await postJson(instance, "/reset-password", {
token,
newPassword: "another-password123",
})
).status,
).toBe(400);
expect(
(
await postJson(instance, "/reset-password", {
token: "invalid",
newPassword: "another-password123",
})
).status,
).toBe(400);
});
});
58 changes: 58 additions & 0 deletions apps/api/src/auth-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { withEmailDeliveryContext } from "./email-delivery-context.js";

const GENERIC_RESET_RESPONSE = {
status: true,
message:
"If this email exists in our system, check your email for the reset link",
};

type AuthRequestHandlerOptions = {
request: Request;
requestId: string;
handler: (request: Request) => Promise<Response>;
passwordResetMinimumMs?: number;
};

function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}

export async function handleAuthRequest({
request,
requestId,
handler,
passwordResetMinimumMs = 3_000,
}: AuthRequestHandlerOptions): Promise<Response> {
const startedAt = Date.now();
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-request-id", requestId);
const requestWithId = new Request(request, { headers: requestHeaders });
const { result, failures } = await withEmailDeliveryContext(() =>
handler(requestWithId),
);

if (new URL(request.url).pathname.endsWith("/request-password-reset")) {
const remaining = passwordResetMinimumMs - (Date.now() - startedAt);
if (remaining > 0) {
await new Promise((resolve) => setTimeout(resolve, remaining));
}

return result.ok ? jsonResponse(GENERIC_RESET_RESPONSE) : result;
}

if (failures.length > 0) {
return jsonResponse(
{
code: "EMAIL_DELIVERY_UNAVAILABLE",
message:
"メールを送信できませんでした。時間をおいて再度お試しください。",
},
503,
);
}

return result;
}
Loading
Loading