Skip to content
29 changes: 26 additions & 3 deletions packages/graphql/src/auth.test.ts
Comment thread
dodok8 marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ const viewerQuery = `
`;

const revokeSessionMutation = `
mutation RevokeSession($session: UUID!) {
revokeSession(session: $session) {
mutation RevokeSession {
revokeSession {
revoke
}
}
Expand Down Expand Up @@ -363,7 +363,6 @@ describe("email authentication", () => {
const revokeResponse = await post(
{
query: revokeSessionMutation,
variables: { session: session.id },
},
authorization,
);
Expand All @@ -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");
});
});
});
26 changes: 9 additions & 17 deletions packages/graphql/src/auth/revoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,21 @@
// 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";

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 };
Expand All @@ -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));
2 changes: 2 additions & 0 deletions packages/web/.oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"es2022": true
},
"rules": {
"eslint/require-await": "off",
"solid/components-return-once": "warn",
"solid/event-handlers": "warn",
"solid/imports": "warn",
Expand All @@ -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"
Expand Down
11 changes: 3 additions & 8 deletions packages/web/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -54,13 +55,7 @@ export default function App() {
About
</A>
</nav>
<A
class={styles.headerAction}
href="/sign-in"
activeClass={styles.active}
>
Sign in
</A>
<HeaderAccountButton />
</div>
</header>
<div class={styles.content}>
Expand Down
154 changes: 154 additions & 0 deletions packages/web/src/components/HeaderAccountButton.tsx
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

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<revokeSessionResult>((resolve) => {
commitMutation<RevokeSession>(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();
Comment thread
dodok8 marked this conversation as resolved.
} 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<HeaderAccountButtonQuery>(
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. */
<ErrorBoundary fallback={() => <></>}>
{/* Avoid auth-state flicker while the viewer query is pending. */}
<Suspense fallback={<></>}>
Comment thread
dodok8 marked this conversation as resolved.
<Show when={query()}>
{(data) => (
<Show
when={data().viewer}
fallback={
<A
class={styles.headerAction}
activeClass={styles.active}
href="/sign-in"
>
Sign in
</A>
}
>
<Button
type="button"
class={styles.headerAction}
disabled={submission.pending}
title={
submission.result?.status === "error"
? submission.result.message
: "Sign Out"
}
aria-live="polite"
onClick={() => void handleSignOut()}
>
{submission.result?.status === "error"
? submission.result.message
: "Sign Out"}
</Button>
</Show>
)}
</Show>
</Suspense>
</ErrorBoundary>
);
}
10 changes: 9 additions & 1 deletion packages/web/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -48,3 +52,7 @@ export function setSessionCookie(
secure: getRequestProtocol() === "https",
});
}

export function deleteSessionCookie(): void {
deleteCookie(SESSION_COOKIE, { path: "/" });
}