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
14 changes: 7 additions & 7 deletions apps/booskiff-web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,13 @@ NixOS では `PLAYWRIGHT_CHROMIUM_PATH=$(command -v chromium)` を指定でき
負の系は Hydra 同意拒否、state 不一致、実 token endpoint の不正 code 拒否を
検証します。署名不正等は既存 `bff/auth-oidc.test.ts` の担当です。

**検出した既存不具合と暫定措置:** 実検証で [#17](https://github.com/ShuttlePub/shuttlepub-frontends/issues/17)
(UI が Kratos 認証後に OAuth を開始しない)と [#19](https://github.com/ShuttlePub/shuttlepub-frontends/issues/19)
(認証済み `/drive` のフルロードで resume DOM が破損する)を検出しました。認証コードは
変更せず、E2E は #17 に対して `/auth/oauth/start` へ明示遷移し、#19 に対して
callback の `return_to` を `/login` として SPA 遷移で回避しています(永続化は API で検証)。
そのため、このスイートは途切れない UI ログイン導線と `/drive` 直接ロードの完成は保証しません。
両 issue の修正後に workaround を外して再検証してください。
**検出した既存不具合と暫定措置:** [#17](https://github.com/ShuttlePub/shuttlepub-frontends/issues/17) /
[#20](https://github.com/ShuttlePub/shuttlepub-frontends/issues/20) のログイン導線は修正済みです。
ログイン成功後に BFF が `next: /auth/oauth/start?return_to=/login` を返し、UI が
トップレベル遷移で OAuth を開始するため、E2E の `/auth/oauth/start` 明示遷移は解除済みです。
[#19](https://github.com/ShuttlePub/shuttlepub-frontends/issues/19)(認証済み `/drive` の
フルロード時の resume DOM 破損)は未修正のため、`return_to=/login` の SPA 経路は
BFF 側で維持し、永続化は引き続き API で検証します。
CI は mock/real を別 matrix job で実行し、両方 green です。

## 構成
Expand Down
79 changes: 79 additions & 0 deletions apps/booskiff-web/bff/auth-real.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import "./test-setup.ts";
import { afterAll, expect, test } from "bun:test";
import { handleAuthRequest, type RealAuthConfig } from "./routes.ts";
import { createTestSessionAdapter } from "./session-test-adapter.ts";
import { TEST_COOKIE_SECRET_BASE64 } from "./test-setup.ts";

// ============================================================
// Kratos ログイン用スタブ
// ============================================================

let credentialsAccepted = true;
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url);
if (req.method === "GET" && url.pathname === "/self-service/login/browser" && url.searchParams.get("refresh") === "true") {
return Response.json({
id: "flow-1", ui: { nodes: [{ attributes: { name: "csrf_token", value: "csrf-token-1" } }] },
}, { headers: { "Set-Cookie": "ory_kratos_flow=flow-cookie; Path=/; HttpOnly" } });
}
if (req.method === "POST" && url.pathname === "/self-service/login" && url.searchParams.get("flow") === "flow-1") {
if (!credentialsAccepted) return Response.json({
ui: { messages: [{ text: "The provided credentials are invalid" }] },
}, { status: 400 });
return Response.json({
session: { identity: { traits: { email: "alice@example.test" } } },
}, { headers: { "Set-Cookie": "ory_kratos_session=kratos-session-cookie; Path=/; HttpOnly" } });
}
return new Response(null, { status: 404 });
},
});
afterAll(() => server.stop(true));
const config: RealAuthConfig = {
kind: "real", kratosPublicUrl: server.url.origin, hydraPublicUrl: "http://hydra.test", hydraClientId: "client",
hydraClientSecret: "secret", hydraRedirectUri: "http://localhost:3000/auth/callback",
hydraScopes: "openid", hydraAudience: "account", oauthStateTtlSeconds: 300,
};
const adapter = createTestSessionAdapter({
cookieSecretBase64: TEST_COOKIE_SECRET_BASE64, sessionCookieName: "booskiff_session",
isSecureOrigin: false, hydraPublicUrl: "http://hydra.test", hydraClientId: "client", hydraClientSecret: "secret", refreshSkewSeconds: 60,
});

function loginRequest(identifier: string, password: string): Request {
return new Request("http://localhost:3000/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json", Origin: "http://localhost:3000" },
body: JSON.stringify({ identifier, password }),
});
}

// ============================================================
// real ログインの OAuth ハンドオフ
// ============================================================

test("returns OAuth next URL and Kratos cookies without an app session when login succeeds", async () => {
// Given: Kratos が credential を受理する。
credentialsAccepted = true;
// When: ブラウザが BFF にログインする。
const response = await handleAuthRequest(loginRequest("alice@example.test", "password"), { adapter, mode: config });
// Then: OAuth 開始 URL と Kratos cookie のみを返す。
expect(response?.status).toBe(200);
expect(await response?.json()).toEqual({
authenticated: true, username: "alice@example.test", next: "/auth/oauth/start?return_to=/login",
});
expect(response?.headers.getSetCookie().some((cookie) => cookie.startsWith("ory_kratos_session="))).toBe(true);
expect(response?.headers.getSetCookie().some((cookie) => cookie.startsWith("booskiff_session="))).toBe(false);
});

test("returns an error without next when Kratos rejects credentials", async () => {
// Given: Kratos が credential を拒否する。
credentialsAccepted = false;
// When: ブラウザが BFF にログインする。
const response = await handleAuthRequest(loginRequest("alice@example.test", "wrong-password"), { adapter, mode: config });
// Then: OAuth に進まず、認証エラーを返す。
expect(response?.status).toBe(401);
const body: unknown = await response?.json();
expect(body).toEqual({ error: "The provided credentials are invalid" });
expect(body).not.toHaveProperty("next");
});
4 changes: 3 additions & 1 deletion apps/booskiff-web/bff/auth-real.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,9 @@ export async function realLogin(req: Request, config: RealAuthConfig, adapter: S

const headers = new Headers({ "Content-Type": "application/json" });
jar.applyToResponse(headers);
return new Response(JSON.stringify({ authenticated: true, username: email }), { status: 200, headers });
// XHR は 302 で cross-origin Hydra chain を進められないため、トップレベル遷移用 URL を渡す。
// return_to=/login は #19 の SPA 経路回避策。修正後に /drive へ戻すことを再検討する。
return new Response(JSON.stringify({ authenticated: true, username: email, next: "/auth/oauth/start?return_to=/login" }), { status: 200, headers });
} catch (err) {
console.error("Kratos login error:", err);
return Response.json({ error: "Authentication service unavailable" }, { status: 502 });
Expand Down
20 changes: 13 additions & 7 deletions apps/booskiff-web/e2e/real/tests/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,22 @@ async function enterCredentials(page: Page): Promise<void> {
await page.goto("/login");
await page.getByTestId("login-identifier").fill("testuser@example.com");
await page.getByTestId("login-password").fill("testuser");
// The login form is hydrated client-side; a click fired before hydration is
// inert, so retry click+wait until the login XHR actually fires.
await expect(async () => {
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL(/\/drive$/, { timeout: 5_000 });
await page.waitForResponse(
(response) => new URL(response.url()).pathname === "/auth/login" && response.request().method() === "POST",
{ timeout: 5_000 },
);
}).toPass({ timeout: 20_000 });
// The current UI omits this real-mode navigation; tracked separately, not patched here.
// return_to=/login: a direct full load of /drive after the OAuth callback corrupts
// the resumed DOM (duplicated folder section, dead handlers); landing on /login and
// letting the session check navigate client-side follows the healthy SPA path.
await page.goto("/auth/oauth/start?return_to=/login");
await expect(page.getByRole("button", { name: "Allow", exact: true })).toBeVisible();
// Real-mode login success hands off to /auth/oauth/start automatically (the
// BFF `next` URL), so the Hydra consent screen arrives through the UI flow
// alone. return_to=/login is applied BFF-side: a direct full load of /drive
// after the OAuth callback corrupts the resumed DOM (issue #19), so landing
// on /login and letting the session check navigate client-side follows the
// healthy SPA path.
await expect(page.getByRole("button", { name: "Allow", exact: true })).toBeVisible({ timeout: 30_000 });
}

async function expectSignedOut(page: Page): Promise<void> {
Expand Down
3 changes: 2 additions & 1 deletion apps/booskiff-web/spago.lock
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"shuttlepub-ui",
"strings",
"tuples",
"web-events"
"web-events",
"web-html"
]
},
"test": {
Expand Down
1 change: 1 addition & 0 deletions apps/booskiff-web/spago.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ package:
- strings
- tuples
- web-events
- web-html
test:
main: Test.Main
dependencies: []
8 changes: 5 additions & 3 deletions apps/booskiff-web/src/App/Api/Auth.purs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import Prelude

import App.Api.Client as Api
import Data.Argonaut.Decode.Class (class DecodeJson, decodeJson)
import Data.Argonaut.Decode.Combinators ((.:))
import Data.Argonaut.Decode.Combinators ((.:), (.:?))
import Data.Either (Either)
import Data.Maybe (Maybe)
import Effect.Aff (Aff)

-- | POST /auth/login body (identifier = email or username)
Expand All @@ -15,14 +16,15 @@ type LoginRequest =
}

-- | Login response from BFF
newtype LoginResponse = LoginResponse { authenticated :: Boolean, username :: String }
newtype LoginResponse = LoginResponse { authenticated :: Boolean, username :: String, next :: Maybe String }

instance DecodeJson LoginResponse where
decodeJson json = do
obj <- decodeJson json
authenticated <- obj .: "authenticated"
username <- obj .: "username"
pure (LoginResponse { authenticated, username })
next <- obj .:? "next"
pure (LoginResponse { authenticated, username, next })

-- | Session response from BFF (GET /auth/session)
newtype SessionResponse = SessionResponse { authenticated :: Boolean, username :: String }
Expand Down
17 changes: 13 additions & 4 deletions apps/booskiff-web/src/Client/Update.purs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ import Flame (Update, noMessages)
import Foreign (unsafeToForeign)
import Routing.Duplex (print)
import Routing.PushState (PushStateInterface)
import Web.HTML (window)
import Web.HTML.Location as Location
import Web.HTML.Window (location)

-- | File input the FFI upload flow reads from (see Client.Upload and the
-- | Drive view owned by the follow-up UX task).
Expand Down Expand Up @@ -311,10 +314,16 @@ checkSessionAff = do
submitLoginAff :: String -> String -> Aff (Maybe Message)
submitLoginAff identifier password = do
result <- Auth.login { identifier, password }
pure $ Just $ case result of
Right (LoginResponse r) | r.authenticated -> SessionChecked (Just r.username)
Right _ -> LoginFailed "Login failed: invalid credentials"
Left err -> LoginFailed err
case result of
Right (LoginResponse r) | r.authenticated -> case r.next of
Just url -> do
-- Real mode hands off to /auth/oauth/start via full-page navigation:
-- XHR cannot drive the cross-origin Hydra redirect chain.
liftEffect (window >>= location >>= Location.assign url)
pure Nothing
Nothing -> pure $ Just $ SessionChecked (Just r.username)
Right _ -> pure $ Just $ LoginFailed "Login failed: invalid credentials"
Left err -> pure $ Just $ LoginFailed err

logoutAff :: Aff (Maybe Message)
logoutAff = do
Expand Down
Loading