diff --git a/packages/graphql/src/auth.test.ts b/packages/graphql/src/auth.test.ts index 330e6cf..a3e335e 100644 --- a/packages/graphql/src/auth.test.ts +++ b/packages/graphql/src/auth.test.ts @@ -92,8 +92,8 @@ const viewerQuery = ` `; const revokeSessionMutation = ` - mutation RevokeSession($session: UUID!) { - revokeSession(session: $session) { + mutation RevokeSession { + revokeSession { revoke } } @@ -363,7 +363,6 @@ describe("email authentication", () => { const revokeResponse = await post( { query: revokeSessionMutation, - variables: { session: session.id }, }, authorization, ); @@ -382,4 +381,28 @@ describe("email authentication", () => { }); }); }); + + it("not sign in, and reovkes the session", async () => { + await withTestHarness(async ({ db, mailer: _, post }) => { + await db.insert(schema.accounts).values({ + id: accountId, + email, + name: "Sign Out Test", + }); + + const revokeResponse = await post({ + query: revokeSessionMutation, + }); + equal(revokeResponse.status, okStatus); + + const responseData = await revokeResponse.json(); + + equal(responseData.data, null); + + const error = responseData.errors[0]; + + equal(error?.message, "Not authorized to resolve Mutation.revokeSession"); + equal(error?.path[0], "revokeSession"); + }); + }); }); diff --git a/packages/graphql/src/auth/revoke.ts b/packages/graphql/src/auth/revoke.ts index bd9a4c8..27e2a59 100644 --- a/packages/graphql/src/auth/revoke.ts +++ b/packages/graphql/src/auth/revoke.ts @@ -17,8 +17,7 @@ // oxlint-disable no-magic-numbers import { sessions } from "@drfed/models/schema"; -import type { Uuid } from "@drfed/models/uuid"; -import { and, eq } from "drizzle-orm/sql/expressions"; +import { eq } from "drizzle-orm/sql/expressions"; import builder, { type UserContext } from "../builder.ts"; @@ -26,16 +25,13 @@ builder.mutationFields((t) => ({ revokeSession: t.field({ type: LogoutSuccessRef, description: "Revokes a session. Return always `revoke: true`.", - args: { - session: t.arg({ - type: "UUID", - required: true, - description: "The session ID to revoke.", - }), + authScopes: { + authenticated: true, }, - async resolve(_query, { session }, ctx) { - if (ctx.session != null) { - await deleteSession(session, ctx); + async resolve(_query, _, ctx) { + const sessionId = ctx.session?.id; + if (sessionId) { + await deleteSession(ctx); } // Return always true to prevent brute-force attack. return { revoke: true }; @@ -59,9 +55,5 @@ const LogoutSuccessRef = builder }), }); -const deleteSession = (id: Uuid, ctx: UserContext) => - ctx.db - .delete(sessions) - .where( - and(eq(sessions.id, id), eq(sessions.accountId, ctx.session!.accountId)), - ); +const deleteSession = (ctx: UserContext) => + ctx.db.delete(sessions).where(eq(sessions.id, ctx.session!.id)); diff --git a/packages/web/.oxlintrc.json b/packages/web/.oxlintrc.json index 39e48ca..95f0e84 100644 --- a/packages/web/.oxlintrc.json +++ b/packages/web/.oxlintrc.json @@ -7,6 +7,7 @@ "es2022": true }, "rules": { + "eslint/require-await": "off", "solid/components-return-once": "warn", "solid/event-handlers": "warn", "solid/imports": "warn", @@ -29,6 +30,7 @@ "typescript/explicit-function-return-type": "off", "typescript/strict-void-return": "off", "typescript/no-non-null-assertion": "off", + "typescript/require-await": "off", "unicorn/filename-case": "off", "unicorn/prefer-query-selector": "off", "promise/avoid-new": "off" diff --git a/packages/web/src/app.tsx b/packages/web/src/app.tsx index 0dd0bbf..0ce0c5f 100644 --- a/packages/web/src/app.tsx +++ b/packages/web/src/app.tsx @@ -17,12 +17,13 @@ import { MetaProvider, Title } from "@solidjs/meta"; import { A, Router } from "@solidjs/router"; import { FileRoutes } from "@solidjs/start/router"; -import { Suspense } from "solid-js"; import "./styles/drfed.css"; import "./styles/app.css"; +import { Suspense } from "solid-js"; import { RelayEnvironmentProvider } from "solid-relay"; +import { HeaderAccountButton } from "./components/HeaderAccountButton.tsx"; import { createRelayEnvironment } from "./RelayEnvironment.ts"; import styles from "./styles/app.module.css"; @@ -54,13 +55,7 @@ export default function App() { About - - Sign in - +
diff --git a/packages/web/src/components/HeaderAccountButton.tsx b/packages/web/src/components/HeaderAccountButton.tsx new file mode 100644 index 0000000..5637600 --- /dev/null +++ b/packages/web/src/components/HeaderAccountButton.tsx @@ -0,0 +1,154 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { Button } from "@kobalte/core/button"; +import { A, action, useAction, useSubmission } from "@solidjs/router"; +import { commitMutation, graphql } from "relay-runtime"; +import { ErrorBoundary, Show, Suspense } from "solid-js"; +import { createLazyLoadQuery } from "solid-relay"; + +import { createRelayEnvironment } from "~/RelayEnvironment.ts"; +import { deleteSessionCookie } from "~/session.ts"; + +import type { HeaderAccountButtonQuery } from "./__generated__/HeaderAccountButtonQuery.graphql.ts"; +import type { RevokeSession } from "./__generated__/RevokeSession.graphql.ts"; + +import styles from "~/styles/app.module.css"; + +const revokeSessionMutation = graphql` + mutation RevokeSession { + revokeSession { + revoke + } + } +`; + +interface revokeSessionResult { + message: string; + status: "error" | "success"; +} + +const signOutAction = action(async () => { + "use server"; + + const environment = createRelayEnvironment(); + + const result = await new Promise((resolve) => { + commitMutation(environment, { + mutation: revokeSessionMutation, + variables: {}, + onCompleted: (_response, errors) => { + const graphQLErrors = errors ?? []; + + if (graphQLErrors.length > 0) { + resolve({ + message: graphQLErrors.map((error) => error.message).join("\n"), + status: "error", + }); + return; + } + + try { + deleteSessionCookie(); + } catch { + resolve({ + message: "Unable to delete cookie", + status: "error", + }); + return; + } + + resolve({ + message: "Signed Out", + status: "success", + }); + }, + onError: (error) => { + resolve({ + message: error.message, + status: "error", + }); + }, + }); + }); + + return result; +}, "sign-out"); + +export function HeaderAccountButton() { + const signOut = useAction(signOutAction); + const submission = useSubmission(signOutAction); + const query = createLazyLoadQuery( + graphql` + query HeaderAccountButtonQuery { + viewer { + name + } + } + `, + {}, + ); + + async function handleSignOut() { + const signOutResult = await signOut(); + + if (signOutResult.status === "success") { + globalThis.location.replace("/"); + } + } + + return ( + /* A query failure does not mean the viewer is signed out. Instead, it means sever error or network error, making access to other function in this situation will make another error. */ + <>}> + {/* Avoid auth-state flicker while the viewer query is pending. */} + }> + + {(data) => ( + + Sign in + + } + > + + + )} + + + + ); +} diff --git a/packages/web/src/session.ts b/packages/web/src/session.ts index 696fba2..0024f5a 100644 --- a/packages/web/src/session.ts +++ b/packages/web/src/session.ts @@ -16,7 +16,11 @@ // oxlint-disable-next-line import/no-unassigned-import -- Environment marker. import "server-only"; -import { getRequestProtocol, setCookie } from "@solidjs/start/http"; +import { + deleteCookie, + getRequestProtocol, + setCookie, +} from "@solidjs/start/http"; const SESSION_COOKIE = "session"; const ACCESS_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/u; @@ -48,3 +52,7 @@ export function setSessionCookie( secure: getRequestProtocol() === "https", }); } + +export function deleteSessionCookie(): void { + deleteCookie(SESSION_COOKIE, { path: "/" }); +}