Compare commits

...

13 commits

Author SHA1 Message Date
f69381c44c Set verbatimModuleSyntax for server code
Some checks failed
/ build (push) Failing after 28s
/ deploy (push) Has been skipped
The nuxi typecheck command complains about type only imports that are
not declared as such, but the VsCode environment does not.  There's
probably a missmatch somewhere in the configuration for Nuxt that I'm
not going to dig into.  Workaround this issue for now by setting the
option in the tsconfig.json file for the server.
2025-07-09 18:10:42 +02:00
0d0e38e4b6 Refactor demo login as an authentication method
Use the authentication method system for the demo login and the
generated accounts.  This makes it possible to toggle it off on
production systems as these shouldn't have it enabled at all.
2025-07-09 18:01:26 +02:00
a33c8e9dac Use SameSite Lax for session cookie
When a user browses to a page from another site, for example via a
shared link we want the browser to send the session cookie so that
the page renders as the user and not confusingly being logged out.

This may cause CSRF vulenrabilities, later work to add CSRF tokens
should be considered.
2025-07-09 15:35:17 +02:00
aaa2faffb1 Implement register and login with Telegram
Add the concept of authentication methods that authenticate an account
where using the telegram login widget is one such method.  If a login is
done with an authentication method that's not associated with any
account the session ends up with the data from the authentication
method in order to allow registering a new account with the
authentication method.

This has to be stored on the session as otherwise it wouldn't be
possible to implement authentication methods such as OAuth2 that takes
the user to a third-party site and then redirects the browser back.
2025-07-09 15:34:57 +02:00
2d6bcebc5a Add note about quoting in configuration guide
The way Nuxt handles environment variables is weird.  Document this to
help others from not falling into its pitfalls.
2025-07-09 14:59:19 +02:00
3f492edea2 Separate rotation and expiry of sessions
If a session is rotate in the middle of a server side rendering then
some random portions of requests made on the server side will fail with
a session taken error as the server is not going to update the cookies
of the client during these requests.

To avoid this pitfall extend the expiry time of sessions to be 10
seconds after the session has been rotated.  This is accomplished by
introducing a new timestamp on sessions called the rotateAt at time
alongside the expiresAt time.  Sessions used after rotateAt that haven't
been rotated get rotated into a new session and the existing session
gets the expiresAt time set to 10 seconds in the future.  Sessions that
are past the expiredAt time have no access.

This makes the logic around session expiry simpler, and also makes it
possible to audit when a session got rotated, and to mark sessions as
expired without a chance to rotate to a new session without having to
resort to a finished flag.
2025-07-09 14:54:54 +02:00
352362b9c3 Ignore deleted users when looking up a user
After the change to converting users to tombstones instead of removing
them from the database several places would accidentally use deleted
user accounts instead of ignoring them.
2025-07-08 16:23:31 +02:00
f4e4dc9f11 Allow abandoning anonymous taken sessions
If an anonymous session is detected as taken the logic preventing the
session from being accidentally deleted would also prevent the user from
recovering from a taken anonymous session.
2025-07-08 16:13:46 +02:00
ebeedff5d0 Add error page for when a session has been taken
Describe to the user what it means when a session has been detected as
taken and provide a means to abandoned the session and log in again.
2025-07-08 16:13:46 +02:00
011687b391 Close event streams for expired sessions
When a session expires close any event streams that have been opened
with that session.  This prevents an attacker with a leaked session
cookie from opening a stream and receiving updates indefinitely without
being detected.

By sending the session the event stream is opened with when the stream
is established this closure on session expiry also serves as a way for
a user agent to be notified whenever its own access level changes.
2025-07-08 16:13:46 +02:00
2d5af78568 Update dependencies 2025-07-07 23:40:27 +02:00
ce1eab7ede Fix syntax error in .editorconfig 2025-07-07 22:51:15 +02:00
1775fac5fd Refactor sessions to frequently rotate
In order to minimise the window of opportunity to steal a session,
automatically rotate it onto a new session on a frequent basis.  This
makes a session cookie older than the automatic rollover time less
likely to grant access and more likely to be detected.

Should a stolen session cookie get rotated while the attacker is using
it, the user will be notificed that their session has been taken the
next time they open the app if the user re-visits the website before the
session is discarded.
2025-07-07 22:50:59 +02:00
41 changed files with 2604 additions and 1756 deletions

View file

@ -10,6 +10,6 @@ indent_style = tab
insert_final_newline = true
trim_trailing_whitespace = true
*{.yaml,.yml}]
[*{.yaml,.yml}]
indent_size = 2
indent_style = space

View file

@ -33,6 +33,7 @@
</template>
<template v-else>
<NuxtLink to="/login">Log In</NuxtLink>
<NuxtLink to="/register">Register</NuxtLink>
</template>
</div>
</header>

29
components/LogIn.vue Normal file
View file

@ -0,0 +1,29 @@
<!--
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div>
<LogInDemo v-if="authDemoEnabled" />
<LogInTelegram v-if="authTelegramEnabled" />
<template v-if="noAuth">No authentication method has been enabled in the configuration.</template>
</div>
</template>
<script lang="ts" setup>
const runtimeConfig = useRuntimeConfig();
const authDemoEnabled = runtimeConfig.public.authDemoEnabled;
const authTelegramEnabled = runtimeConfig.public.authTelegramEnabled;
const noAuth =
!authDemoEnabled
&& !authTelegramEnabled
;
</script>
<style lang="css" scoped>
div>* + *::before {
content: "\2013 or \2013 ";
display: block;
margin-block: 0.5rem;
}
</style>

41
components/LogInDemo.vue Normal file
View file

@ -0,0 +1,41 @@
<!--
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<form @submit.prevent="logIn">
<label>
Demo:
<input v-model="name" type="text" placeholder="Name" required>
<button type="submit">Log In</button>
</label>
</form>
</template>
<script lang="ts" setup>
const sessionStore = useSessionStore();
const route = useRoute();
const name = ref("");
async function logIn() {
try {
const session = await $fetch("/api/auth/ap/demo-login", {
method: "POST",
body: { name: name.value },
});
await sessionStore.update(session);
if (!session.account) {
await navigateTo("/register");
} else if (route.path === "/register") {
await navigateTo("/");
}
} catch (err: any) {
alert(err.data?.message ?? err.message);
}
}
</script>
<style>
</style>

View file

@ -0,0 +1,61 @@
<!--
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div ref="div">
<button
v-if="!loaded"
@click="load"
>
Enable Log in with Telegram
</button>
</div>
</template>
<script lang="ts" setup>
import type { TelegramAuthData } from '~/shared/types/telegram';
const divElement = useTemplateRef("div");
const loaded = ref(false);
const runtimeConfig = useRuntimeConfig();
const sessionStore = useSessionStore();
const route = useRoute();
function load() {
const script = document.createElement("script");
script.async = true;
script.src = "https://telegram.org/js/telegram-widget.js?22";
script.dataset.telegramLogin = runtimeConfig.public.telegramBotUsername;
script.dataset.size = "medium";
script.dataset.onauth = "onTelegramAuth(user)";
script.dataset.requestAccess = "write";
divElement.value?.appendChild(script);
loaded.value = true;
}
async function login(authData: TelegramAuthData) {
const session = await $fetch("/api/auth/ap/telegram-login", {
method: "POST",
body: {
authData,
},
});
sessionStore.update(session);
if (!session.account) {
await navigateTo("/register");
} else if (route.path === "/register") {
await navigateTo("/");
}
}
declare global {
export function onTelegramAuth(user: any): void;
}
onMounted(() => {
// XXX this asumes there's only ever one LogInTelegram component mounted
window.onTelegramAuth = function(authData: TelegramAuthData) {
login(authData).catch(err => alert(err.data?.message ?? err.message));
};
});
</script>

View file

@ -22,8 +22,11 @@ class AppEventSource extends EventTarget {
console.log("AppEventSource", event.type, event.data);
this.dispatchEvent(new Event(event.type));
} else {
const data = event.data ? JSON.parse(event.data) : undefined;
const data = event.data ? JSON.parse(event.data) as ApiEvent : undefined;
console.log("AppEventSource", event.type, data);
if (data?.type === "connected") {
this.#sourceSessionId = data.session?.id;
}
this.dispatchEvent(new MessageEvent(event.type, {
data,
origin: event.origin,

View file

@ -0,0 +1,15 @@
<!--
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
-->
# Authentication
It's possible to configure authentication using a third party Authentication Provider (referred to as AP). Currently only Telegram is supported as an AP.
## Telegram
In order to use Telegram as an AP you need to be hosting Owltide under a domain name over https, using http will not work.
You will also need a bot which can be created by messaging [@BotFather](https://t.me/BotFather), with the domain of the bot set using the `/setdomain` command to the domain Owltide is hosted under.
Once you have the pre-requisites you need to configure `NUXT_TELEGRAM_BOT_TOKEN_FILE` to a path to a file containing the token of the bot with no spaces or new-lines. `NUXT_PUBLIC_TELEGRAM_BOT_USERNAME` to the username of the bot. And finally `NUXT_AUTH_TELEGRAM_ENABLED` to `true` to enable authentication via Telegram.

45
docs/admin/config.md Normal file
View file

@ -0,0 +1,45 @@
<!--
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
-->
# Configuration
## Quoting
Environment variables are parsed using [destr](https://github.com/unjs/destr) which contain arbitrary unspecified and undocumented rules for converting strings to data. If an environment input looks like JSON it'll most likely be parsed as JSON and my cause a type missmatch error to be reported. To avoid strings being converted into other unintended values put the value into `"` marks. Depending on your configuration environment you may have to double up the quotation marks and/or use escapes.
## Environment Variables
### NUXT_SESSION_ROTATES_TIMEOUT
Time in seconds before a session need to be rotated over into a new session. When an endpoint using a session is hit after the session rotates timeout but before the session is discarded a new session is created as the successor with a new rotates and discard timeout. The old session then marked to expire in 10 seconds any requests using the old session will result in a 403 Forbidden with the message the session has been taken after the expiry.
### NUXT_SESSION_DISCARD_TIMEOUT
Time in seconds before a session is deleted from the client and server, resulting in the user having to authenticate again if the session wasn't rotated over into a new session before this timeout.
This should be several times greater that `NUXT_SESSION_ROTATES_TIMEOUT`.
### NUXT_AUTH_DEMO_ENABLED
Boolean indicating if the demo authentication provider should be enabled. This allows logging in using only a name with no additional checks or security and should _never_ be enabled on a production system. The purpose of this is to make it easier to demo the system.
Defaults to `false`.
### NUXT_TELEGRAM_BOT_TOKEN_FILE
Path to a file containing the token for the Telegram bot used for authenticating users via Telegram.
Does nothing if `NUXT_AUTH_TELEGRAM_ENABLED` is not enabled.
### NUXT_PUBLIC_TELEGRAM_BOT_USERNAME
Username of the Telegram bot used for authenticating users via Telegram.
Does nothing if `NUXT_AUTH_TELEGRAM_ENABLED` is not enabled.
### NUXT_AUTH_TELEGRAM_ENABLED
Boolean indicating if authentication via Telegram is enabled or not. Requires `NUXT_PUBLIC_TELEGRAM_BOT_USERNAME` and `NUXT_TELEGRAM_BOT_TOKEN_FILE` to be set in order to work.
Defaults to `false`.

View file

@ -0,0 +1,13 @@
<!--
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
-->
# Server-sent events
To update in real time this application sends a `text/event-source` stream using [Server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events). These streams use the current session if any to filter restricted resources and ends when the session rotates timeout is hit, necessitating a reconnect by the user agent. (If there are no session associated with the connection it ends after the session rotates timeout).
## Events
Upon connecting a `"connect"` event is emitted with the session the connection was made under. This is the primary mechanism a user agent discovers its own session having been rotated into a new one, which also happens when the access level of the account associated with the session changes.
After the `"connect"` event the user agent will start to receive updates to resources it has access to that has changed. There is no filtering for what resoucres the user agent receives updates for at the moment as there's not enough events to justify the complexity of server-side subscriptions and filtering.

13
docs/dev/sessions.md Normal file
View file

@ -0,0 +1,13 @@
<!--
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
-->
# Sessions
When a user creates a new account or logs in to an existing account a session is created on the server and linked to the user's browser via a session cookie. This cookie contains a unique identifier along with a HMAC signature created using the server's cookie secret key. Since this unique identifier stored on the user's device is a technical requirement to securely do what the user is requesting the user's consent to its storage can be assumed.
Sessions have three future times associated with them: The rotates time is the point in time after the session will be recreated and the cookie reassigned, the expiry time is the point in time after which use of the session will be rejected, and the discard time is when the session is deleted from both the client and the server. The rotation time is short, by default 1 hour, while the discard time is long, by default 2 weeks.
When a request is made to a session that's past the rotates time a new session is created to replace the existing one, the expiry time is set on the existing session to 10 seconds later, and the session cookie is updated with the new session. The purpose of this is to reduce the time window a stolen session can be used in without being detected. If a request is made using a session that has expired the server responds with a message saying the "session has been taken". The reason for having the session expire 10 seconds after the rotation is to prevent race conditions from triggering the session taken error.
Sessions are created for a limited timespan, purpose and access level, and expires after the timespan is over, the purpose is fulfilled or the access level changes. For example if the user's account is promoted from regular to crew the session will no longer be valid and will be recreated as a new session with the new access level on the next request.

View file

@ -3,26 +3,65 @@
SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<Header />
<h1>{{ error.statusCode }} {{ error.statusMessage }}</h1>
<p v-if="error.message !== error.statusMessage">
{{ error.message }}
</p>
<pre v-if="error.stack"><code>{{ error.stack }}</code></pre>
<template v-if='data?.data?.code === "SESSION_TAKEN"'>
<h1>Session taken</h1>
<p>
Your session with the server has been taken over by another browser, device, or HTTP agent. This could happen due to one of the following reasons:
</p>
<ul>
<li>
The session cookie on this device got restored to an earlier state for example as a result of a restore or a crash.
</li>
<li>
The server issued a new session to the client but the client didn't get the repsonse.
</li>
<li>
The session cookie was copied and used on another device, browser or HTTP agent.
</li>
</ul>
<p>
It's possible however unlikely that someone else have hijacked your session.
</p>
<p>
<button
type="button"
@click="abandonSession"
>
Abandon Session
</button>
</p>
</template>
<template v-else>
<Header />
<h1>{{ error.statusCode }} {{ error.statusMessage }}</h1>
<p v-if="error.message !== error.statusMessage">
{{ error.message }}
</p>
<pre v-if="error.stack"><code>{{ error.stack }}</code></pre>
</template>
</template>
<script setup lang="ts">
defineProps<{ error: {
const props = defineProps<{ error: {
statusCode: number,
fatal: boolean,
unhandled: boolean,
statusMessage?: string,
data?: unknown,
data?: string,
cause?: unknown,
// Undocumented fields
url?: string,
message?: string,
stack?: string,
} }>()
}}>();
const data = computed<{
data?: {
code?: string,
},
}>(() => props.error.data ? JSON.parse(props.error.data) : undefined);
async function abandonSession() {
await $fetch("/api/auth/session", { method: "DELETE", }).catch(err => alert(err.message));
await navigateTo("/");
}
</script>

View file

@ -4,6 +4,8 @@
*/
// https://nuxt.com/docs/api/configuration/nuxt-config
const enableDevtools = !process.env.DISABLE_DEV_TOOLS
const oneHourSeconds = 60 * 60;
const oneDaySeconds = 24 * oneHourSeconds;
export default defineNuxtConfig({
experimental: { renderJsonPayloads: true },
compatibilityDate: '2024-11-01',
@ -21,11 +23,17 @@ export default defineNuxtConfig({
},
runtimeConfig: {
cookieSecretKeyFile: "",
sessionRotatesTimeout: 1 * oneHourSeconds,
sessionDiscardTimeout: 14 * oneDaySeconds,
vapidSubject: "",
vapidPrivateKeyFile: "",
telegramBotTokenFile: "",
public: {
defaultTimezone: "Europe/Oslo",
defaultLocale: "en-GB",
authDemoEnabled: false,
authTelegramEnabled: false,
telegramBotUsername: "",
vapidPublicKey: "",
}
},

View file

@ -11,14 +11,14 @@
"postinstall": "nuxt prepare"
},
"dependencies": {
"@pinia/nuxt": "0.11.0",
"luxon": "^3.5.0",
"nuxt": "^3.17.4",
"pinia": "^3.0.2",
"vue": "latest",
"vue-router": "latest",
"@pinia/nuxt": "^0.11.1",
"luxon": "^3.6.1",
"nuxt": "^3.17.6",
"pinia": "^3.0.3",
"vue": "^3.5.17",
"vue-router": "^4.5.1",
"web-push": "^3.6.7",
"zod": "^3.25.30"
"zod": "^3.25.75"
},
"packageManager": "pnpm@10.5.2+sha512.da9dc28cd3ff40d0592188235ab25d3202add8a207afbedc682220e4a0029ffbff4562102b9e6e46b4e3f9e8bd53e6d05de48544b0c57d4b0179e22c76d1199b",
"pnpm": {
@ -28,11 +28,11 @@
]
},
"devDependencies": {
"@nuxt/test-utils": "^3.19.1",
"@types/luxon": "^3.4.2",
"@nuxt/test-utils": "^3.19.2",
"@types/luxon": "^3.6.2",
"@types/web-push": "^3.6.4",
"happy-dom": "^17.6.3",
"vitest": "^3.2.3",
"vue-tsc": "^2.2.10"
"vitest": "^3.2.4",
"vue-tsc": "^3.0.1"
}
}

View file

@ -5,31 +5,10 @@
<template>
<main>
<h1>Log In</h1>
<form @submit.prevent="logIn">
<input v-model="name" type="text" placeholder="Name" required>
<button type="submit">Log In</button>
</form>
<h2 id="create-account">Create Account</h2>
<p>If you don't have an account you may create one</p>
<form @submit.prevent="createAccount">
<fieldset>
<legend>Regular Account</legend>
<label>
Name:
<input v-model="createName" type="text" placeholder="Name" required />
</label>
<button type="submit">Create account</button>
</fieldset>
</form>
<LogIn />
<p>
If you do not wish to deal with logins you may create an anonymous account tied to this device.
If you don't have an account you may <NuxtLink to="/register">register for one</NuxtLink>.
</p>
<fieldset>
<legend>Anonymous Account</legend>
<button type="button" @click="createAnonymousAccount">Create anonymous account</button>
</fieldset>
<pre><code>{{ result }}</code></pre>
<pre><code>Session: {{ ({ id: sessionStore.id, account: sessionStore.account, push: sessionStore.push }) }}</code></pre>
</main>
</template>
@ -37,64 +16,4 @@
useHead({
title: "Login",
});
const sessionStore = useSessionStore();
const { getSubscription, subscribe } = usePushNotification();
const name = ref("");
const result = ref("")
async function logIn() {
try {
result.value = await sessionStore.logIn(name.value);
// Resubscribe push notifications if the user was subscribed before.
const subscription = await getSubscription();
if (subscription) {
await subscribe();
}
// XXX Remove the need for this.
await sessionStore.fetch();
} catch (err: any) {
console.log(err);
console.log(err.data);
result.value = `Server replied: ${err.statusCode} ${err.statusMessage}`;
}
}
const createName = ref("");
async function createAccount() {
try {
const res = await $fetch.raw("/api/auth/account", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({ name: createName.value })
});
result.value = `Server replied: ${res.status} ${res.statusText}`;
await sessionStore.fetch();
} catch (err: any) {
console.log(err);
console.log(err.data);
result.value = `Server replied: ${err.statusCode} ${err.statusMessage}`;
}
}
async function createAnonymousAccount() {
try {
const res = await $fetch.raw("/api/auth/account", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
result.value = `Server replied: ${res.status} ${res.statusText}`;
await sessionStore.fetch();
} catch (err: any) {
console.log(err);
console.log(err.data);
result.value = `Server replied: ${err.statusCode} ${err.statusMessage}`;
}
}
</script>

102
pages/register.vue Normal file
View file

@ -0,0 +1,102 @@
<!--
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<main>
<h1>Register</h1>
<div class="card">
<h2>Regular User</h2>
<p>
Set up an authentication method and choose a username to register a new user.
</p>
<h3>Authentication Method</h3>
<p v-if="sessionStore.authenticationProvider">
Provider: {{ sessionStore.authenticationProvider }}
<br>Identifier: {{ sessionStore.authenticationName }}
<br><button type="button" @click="clearProvider">Clear Method</button>
</p>
<LogIn v-else />
<form @submit.prevent="register(false)">
<h3>User Details</h3>
<p>
<label>
Username
<input type="text" v-model="username" required>
</label>
</p>
<p>
<button
type="submit"
:disabled="!sessionStore.authenticationProvider"
>
Register new user
</button>
</p>
</form>
</div>
<div class="alternatively">
&ndash; or &ndash;
</div>
<div class="card">
<h2>Anonymous User</h2>
<p>
If you do not wish to deal with logins you may create an
anonymous user authenticated with a session cookie stored on
this device. Since there's no persistent authentication for the
user should you lose the session cookie you will also lose
access to the user.
</p>
<p>
<button type="button" @click="register(true)">
Create anonymous user
</button>
</p>
</div>
</main>
</template>
<script setup lang="ts">
useHead({
title: "Register",
});
const sessionStore = useSessionStore();
const username = ref("");
async function clearProvider() {
sessionStore.logOut();
}
async function register(anonymous: boolean) {
let session;
try {
session = await $fetch("/api/auth/account", {
method: "POST",
body: anonymous ? undefined : {
name: username.value,
},
});
} catch (err: any) {
alert(err.data?.message ?? err.message);
return;
}
sessionStore.update(session);
if (session.account) {
await navigateTo("/account/settings");
}
}
</script>
<style scoped>
.alternatively {
margin-block: 1rem;
}
.card {
background: color-mix(in oklab, var(--background), grey 20%);
padding: 0.5rem;
border-radius: 0.5rem;
}
.card h2 {
margin-block-start: 0;
}
</style>

3191
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -2,10 +2,19 @@
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { writeSchedule, writeUsers } from "~/server/database";
import { nextAuthenticationMethodId, writeAuthenticationMethods, writeNextAuthenticationMethodId, writeSchedule, writeUsers } from "~/server/database";
import { generateDemoSchedule, generateDemoAccounts } from "~/server/generate-demo-schedule";
export default defineEventHandler(async (event) => {
await requireServerSessionWithAdmin(event);
await writeUsers(generateDemoAccounts());
const accounts = generateDemoAccounts();
await writeUsers(accounts);
await writeSchedule(generateDemoSchedule());
await writeAuthenticationMethods(accounts.map((user, index) => ({
id: index,
userId: user.id,
provider: "demo",
slug: user.name!,
name: user.name!,
})));
await writeNextAuthenticationMethodId(Math.max(await nextAuthenticationMethodId(), accounts.length));
})

View file

@ -40,7 +40,7 @@ export default defineEventHandler(async (event) => {
// Only keep sessions that match the account id in both sets to avoid
// resurrecting deleted sessions. This will still cause session cross
// pollution if a snapshot from another instance is loaded here.
return current && current.account.id === session.account.id;
return current?.accountId !== undefined && current.accountId === session.accountId;
}));
await writeSubscriptions(snapshot.subscriptions);
await writeSchedule(snapshot.schedule);

View file

@ -2,7 +2,7 @@
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { readUsers, writeUsers } from "~/server/database";
import { readSessions, readUsers, writeSessions, writeUsers } from "~/server/database";
import { apiUserPatchSchema } from "~/shared/types/api";
import { z } from "zod/v4-mini";
import { broadcastEvent } from "~/server/streams";
@ -29,7 +29,8 @@ export default defineEventHandler(async (event) => {
}
if (patch.type) {
let accessChanged = false;
if (patch.type && patch.type !== user.type) {
if (patch.type === "anonymous" || user.type === "anonymous") {
throw createError({
status: 409,
@ -38,6 +39,7 @@ export default defineEventHandler(async (event) => {
});
}
user.type = patch.type;
accessChanged = true;
}
if (patch.name) {
if (user.type === "anonymous") {
@ -54,7 +56,23 @@ export default defineEventHandler(async (event) => {
broadcastEvent({
type: "user-update",
data: serverUserToApi(user),
})
});
// Rotate sessions with the user in it if the access changed
if (accessChanged) {
const sessions = await readSessions();
const nowMs = Date.now();
for (const session of sessions) {
if (session.accountId === user.id) {
session.rotatesAtMs = nowMs;
broadcastEvent({
type: "session-expired",
sessionId: session.id,
});
}
}
await writeSessions(sessions);
}
// Update Schedule counts.
await updateScheduleInterestedCounts(users);

View file

@ -9,33 +9,41 @@ import {
import { broadcastEvent, cancelAccountStreams } from "~/server/streams";
export default defineEventHandler(async (event) => {
const serverSession = await requireServerSession(event);
const serverSession = await requireServerSessionWithUser(event);
let users = await readUsers();
// Remove sessions for this user
const removedSessionIds = new Set<number>();
// Expire sessions for this user
const expiredSessionIds = new Set<number>();
let sessions = await readSessions();
sessions = sessions.filter(session => {
if (session.account.id === serverSession.account.id) {
removedSessionIds.add(session.id);
return false;
const nowMs = Date.now();
for (const session of sessions) {
if (
session.successor !== undefined
&& (session.expiresAtMs === undefined || session.expiresAtMs < nowMs)
&& session.accountId === serverSession.accountId
) {
session.expiresAtMs = nowMs;
broadcastEvent({
type: "session-expired",
sessionId: session.id,
});
expiredSessionIds.add(session.id);
}
return true;
});
cancelAccountStreams(serverSession.account.id);
}
cancelAccountStreams(serverSession.accountId);
await writeSessions(sessions);
await deleteCookie(event, "session");
// Remove subscriptions for this user
let subscriptions = await readSubscriptions();
subscriptions = subscriptions.filter(
subscription => !removedSessionIds.has(subscription.sessionId)
subscription => !expiredSessionIds.has(subscription.sessionId)
);
await writeSubscriptions(subscriptions);
// Remove the user
const account = users.find(user => user.id === serverSession.account.id)!;
const now = new Date().toISOString();
const account = users.find(user => user.id === serverSession.accountId)!;
const now = new Date(nowMs).toISOString();
account.deleted = true;
account.updatedAt = now;
await writeUsers(users);

View file

@ -9,7 +9,7 @@ import { z } from "zod/v4-mini";
export default defineEventHandler(async (event) => {
const session = await requireServerSession(event);
const session = await requireServerSessionWithUser(event);
const { success, error, data: patch } = apiAccountPatchSchema.safeParse(await readBody(event));
if (!success) {
throw createError({
@ -39,7 +39,7 @@ export default defineEventHandler(async (event) => {
}
const users = await readUsers();
const account = users.find(user => user.id === session.account.id);
const account = users.find(user => user.id === session.accountId);
if (!account) {
throw Error("Account does not exist");
}

View file

@ -2,20 +2,21 @@
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { readUsers, writeUsers, nextUserId, type ServerUser } from "~/server/database";
import { readUsers, writeUsers, nextUserId, type ServerUser, readAuthenticationMethods, nextAuthenticationMethodId, writeAuthenticationMethods } from "~/server/database";
import { broadcastEvent } from "~/server/streams";
import type { ApiSession } from "~/shared/types/api";
export default defineEventHandler(async (event) => {
let session = await getServerSession(event);
if (session) {
export default defineEventHandler(async (event): Promise<ApiSession> => {
let session = await getServerSession(event, false);
if (session?.accountId !== undefined) {
throw createError({
status: 409,
message: "Cannot create account while having an active session."
message: "Cannot create account while logged in to an account."
});
}
const formData = await readFormData(event);
const name = formData.get("name");
const body = await readBody(event);
const name = body?.name;
const users = await readUsers();
let user: ServerUser;
@ -41,7 +42,7 @@ export default defineEventHandler(async (event) => {
name,
};
} else if (name === null) {
} else if (name === undefined) {
user = {
id: await nextUserId(),
updatedAt: new Date().toISOString(),
@ -54,11 +55,42 @@ export default defineEventHandler(async (event) => {
});
}
if (user.type !== "anonymous") {
if (!session?.authenticationProvider) {
throw createError({
statusCode: 409,
statusMessage: "Conflict",
message: "User account need an authentication method associated with it.",
});
}
const authMethods = await readAuthenticationMethods();
const method = authMethods.find(method => (
method.provider === session.authenticationProvider
&& method.slug === session.authenticationSlug
));
if (method) {
throw createError({
statusCode: 409,
statusMessage: "Conflict",
message: "A user is already associated with the authentication method",
});
}
authMethods.push({
id: await nextAuthenticationMethodId(),
userId: user.id,
provider: session.authenticationProvider,
slug: session.authenticationSlug!,
name: session.authenticationName!,
})
await writeAuthenticationMethods(authMethods);
}
users.push(user);
await writeUsers(users);
await broadcastEvent({
type: "user-update",
data: user,
});
await setServerSession(event, user);
const newSession = await setServerSession(event, user);
return await serverSessionToApi(event, newSession);
})

View file

@ -0,0 +1,38 @@
/*
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { readAuthenticationMethods, readUsers } from "~/server/database";
export default defineEventHandler(async (event) => {
const runtimeConfig = useRuntimeConfig(event);
if (!runtimeConfig.public.authDemoEnabled) {
throw createError({
statusCode: 403,
statusMessage: "Forbidden",
message: "Demo authentication is disabled",
});
}
const { name: slug } = await readBody(event);
if (typeof slug !== "string" || !slug) {
throw createError({
statusCode: 400,
statusMessage: "Bad Request",
message: "Missing name",
});
}
const authMethods = await readAuthenticationMethods();
const method = authMethods.find(method => method.provider === "demo" && method.slug === slug);
let session;
if (method) {
const users = await readUsers();
const account = users.find(user => !user.deleted && user.id === method.userId);
session = await setServerSession(event, account);
} else {
session = await setServerSession(event, undefined, "demo", slug, slug);
}
return await serverSessionToApi(event, session);
})

View file

@ -0,0 +1,100 @@
/*
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import * as fs from "node:fs/promises";
import type { H3Event } from "h3";
import { z } from "zod/v4-mini";
import { readAuthenticationMethods, readUsers } from "~/server/database";
import { type TelegramAuthData, telegramAuthDataSchema } from "~/shared/types/telegram";
import type { ApiSession } from "~/shared/types/api";
const loginSchema = z.object({
authData: telegramAuthDataSchema,
});
let cachedTelegramConfig:
| undefined
| { enabled: false }
| { enabled: true, botUsername: string, secretKey: CryptoKey }
;
async function useTelegramConfig(event: H3Event) {
if (cachedTelegramConfig)
return cachedTelegramConfig;
const runtimeConfig = useRuntimeConfig(event);
if (!runtimeConfig.public.authTelegramEnabled) {
return cachedTelegramConfig = {
enabled: false,
};
}
if (!runtimeConfig.telegramBotTokenFile) throw new Error("NUXT_TELEGRAM_BOT_TOKEN_FILE not configured");
if (!runtimeConfig.public.telegramBotUsername) throw new Error("NUXT_PUBLIC_TELEGRAM_BOT_USERNAME not configured");
const botToken = await fs.readFile(runtimeConfig.telegramBotTokenFile);
const secretKey = await crypto.subtle.importKey(
"raw",
await crypto.subtle.digest("SHA-256", botToken),
{
name: "HMAC",
hash: "SHA-256",
},
false,
["verify"],
);
return cachedTelegramConfig = {
enabled: true,
botUsername: runtimeConfig.public.telegramBotUsername,
secretKey,
}
}
async function validateTelegramAuthData(authData: TelegramAuthData, key: CryptoKey) {
const { hash, ...checkData } = authData;
const checkString = Object.entries(checkData).map(([key, value]) => `${key}=${value}`).sort().join("\n");
const signature = Buffer.from(hash, "hex");
return await crypto.subtle.verify("HMAC", key, signature, Buffer.from(checkString));
}
export default defineEventHandler(async (event): Promise<ApiSession> => {
const { success, error, data } = loginSchema.safeParse(await readBody(event));
if (!success) {
throw createError({
statusCode: 400,
statusMessage: "Bad Request",
message: z.prettifyError(error),
});
}
const config = await useTelegramConfig(event);
if (!config.enabled) {
throw createError({
statusCode: 403,
statusMessage: "Forbidden",
message: "Telegram authentication is disabled",
});
}
if (!await validateTelegramAuthData(data.authData, config.secretKey)) {
throw createError({
statusCode: 403,
statusMessage: "Forbidden",
message: "Validating authentication data failed",
});
}
const slug = String(data.authData.id);
const authMethods = await readAuthenticationMethods();
const method = authMethods.find(method => method.provider === "telegram" && method.slug === slug);
let session;
if (method) {
const users = await readUsers();
const account = users.find(user => !user.deleted && user.id === method.userId);
session = await setServerSession(event, account);
} else {
const name = data.authData.username ? "@" + data.authData.username : slug;
session = await setServerSession(event, undefined, "telegram", slug, name);
}
return await serverSessionToApi(event, session);
})

View file

@ -1,22 +0,0 @@
/*
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { readUsers } from "~/server/database";
export default defineEventHandler(async (event) => {
const { name } = await readBody(event);
if (!name) {
return new Response(undefined, { status: 400 })
}
const accounts = await readUsers();
const account = accounts.find(a => a.name === name);
if (!account) {
return new Response(undefined, { status: 403 })
}
await setServerSession(event, account);
})

View file

@ -2,12 +2,15 @@
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { readUsers } from "~/server/database";
import { cancelSessionStreams } from "~/server/streams";
export default defineEventHandler(async (event) => {
const session = await getServerSession(event);
const session = await getServerSession(event, true);
if (session) {
if (session.account.type === "anonymous") {
const users = await readUsers();
const account = users.find(user => user.id === session.accountId);
if (account?.type === "anonymous") {
throw createError({
status: 409,
message: "Cannot log out of an anonymous account",
@ -15,8 +18,5 @@ export default defineEventHandler(async (event) => {
}
}
if (session) {
cancelSessionStreams(session.id);
}
await clearServerSession(event);
})

View file

@ -2,23 +2,10 @@
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { readSubscriptions } from "~/server/database";
import type { ApiSession } from "~/shared/types/api";
export default defineEventHandler(async (event): Promise<ApiSession | undefined> => {
const session = await getServerSession(event);
export default defineEventHandler(async event => {
const session = await getServerSession(event, false);
if (!session)
return;
const subscriptions = await readSubscriptions();
const push = Boolean(
subscriptions.find(sub => sub.type === "push" && sub.sessionId === session.id)
);
await refreshServerSession(event, session);
return {
id: session.id,
account: session.account,
push,
};
return await serverSessionToApi(event, session);
})

View file

@ -6,8 +6,7 @@ import { pipeline } from "node:stream";
import { addStream, deleteStream } from "~/server/streams";
export default defineEventHandler(async (event) => {
const session = await getServerSession(event);
const accountId = session?.account.id;
const session = await getServerSession(event, false);
const encoder = new TextEncoder();
const source = event.headers.get("x-forwarded-for");
@ -25,8 +24,8 @@ export default defineEventHandler(async (event) => {
console.log(`cancelled event stream for ${source}`);
deleteStream(stream.writable);
}
})
addStream(stream.writable, session?.id, accountId);
});
addStream(event, stream.writable, session);
// Workaround to properly handle stream errors. See https://github.com/unjs/h3/issues/986
setHeader(event, "Access-Control-Allow-Origin", "*");

View file

@ -9,9 +9,9 @@ import { apiScheduleSchema } from "~/shared/types/api";
import { applyUpdatesToArray } from "~/shared/utils/update";
export default defineEventHandler(async (event) => {
const session = await requireServerSession(event);
const session = await requireServerSessionWithUser(event);
if (session.account.type !== "admin" && session.account.type !== "crew") {
if (session.access !== "admin" && session.access !== "crew") {
throw createError({
status: 403,
statusMessage: "Forbidden",
@ -45,7 +45,7 @@ export default defineEventHandler(async (event) => {
}
// Validate edit restrictions for crew
if (session.account.type === "crew") {
if (session.access === "crew") {
if (update.locations?.length) {
throw createError({
status: 403,

View file

@ -5,7 +5,7 @@
import { readSchedule } from "~/server/database";
export default defineEventHandler(async (event) => {
const session = await getServerSession(event);
const session = await getServerSession(event, false);
const schedule = await readSchedule();
return canSeeCrew(session?.account.type) ? schedule : filterSchedule(schedule);
return canSeeCrew(session?.access) ? schedule : filterSchedule(schedule);
});

View file

@ -11,7 +11,7 @@ const subscriptionSchema = z.strictObject({
});
export default defineEventHandler(async (event) => {
const session = await requireServerSession(event);
const session = await requireServerSessionWithUser(event);
const { success, error, data: body } = subscriptionSchema.safeParse(await readBody(event));
if (!success) {
throw createError({

View file

@ -5,7 +5,7 @@
import { readSubscriptions, writeSubscriptions } from "~/server/database";
export default defineEventHandler(async (event) => {
const session = await requireServerSession(event);
const session = await requireServerSessionWithUser(event);
const subscriptions = await readSubscriptions();
const existingIndex = subscriptions.findIndex(
sub => sub.type === "push" && sub.sessionId === session.id

View file

@ -5,13 +5,13 @@
import { readUsers } from "~/server/database"
export default defineEventHandler(async (event) => {
const session = await requireServerSession(event);
const session = await requireServerSessionWithUser(event);
const users = await readUsers();
if (session.account.type === "admin") {
if (session.access === "admin") {
return users.map(serverUserToApi);
}
if (session.account.type === "crew") {
if (session.access === "crew") {
return users.filter(u => u.type === "crew" || u.type === "admin").map(serverUserToApi);
}
throw createError({

View file

@ -3,19 +3,22 @@
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { readFile, unlink, writeFile } from "node:fs/promises";
import type { ApiSchedule, ApiSubscription, ApiUserType } from "~/shared/types/api";
import type { ApiAuthenticationProvider, ApiSchedule, ApiSubscription, ApiUserType } from "~/shared/types/api";
import type { Id } from "~/shared/types/common";
export interface ServerSession {
id: number,
account: ServerUser,
id: Id,
access: ApiUserType,
accountId?: number,
authenticationProvider?: ApiAuthenticationProvider,
authenticationSlug?: string,
authenticationName?: string,
rotatesAtMs: number,
expiresAtMs?: number,
discardAtMs: number,
successor?: Id,
};
interface StoredSession {
id: number,
accountId: number,
}
export interface ServerUser {
id: Id,
updatedAt: string,
@ -28,6 +31,14 @@ export interface ServerUser {
locale?: string,
}
export interface ServerAuthenticationMethod {
id: Id,
provider: ApiAuthenticationProvider,
slug: string,
name: string,
userId: Id,
}
// For this demo I'm just storing the runtime data in JSON files. When making
// this into proper application this should be replaced with an actual database.
@ -37,6 +48,8 @@ const usersPath = "data/users.json";
const nextUserIdPath = "data/next-user-id.json";
const sessionsPath = "data/sessions.json";
const nextSessionIdPath = "data/next-session-id.json";
const authMethodPath = "data/auth-method.json";
const nextAuthenticationMethodIdPath = "data/auth-method-id.json"
async function remove(path: string) {
try {
@ -131,26 +144,27 @@ export async function nextSessionId() {
}
export async function readSessions() {
const users = await readUsers();
const sessions: ServerSession[] = [];
for (const stored of await readJson<StoredSession[]>(sessionsPath, [])) {
const user = users.find(user => user.id === stored.accountId);
if (user) {
sessions.push({
id: stored.id,
account: user,
});
}
}
return sessions;
return readJson<ServerSession[]>(sessionsPath, [])
}
export async function writeSessions(sessions: ServerSession[]) {
const stored: StoredSession[] = sessions.map(
session => ({
id: session.id,
accountId: session.account.id,
}),
);
await writeFile(sessionsPath, JSON.stringify(stored, undefined, "\t") + "\n", "utf-8");
await writeFile(sessionsPath, JSON.stringify(sessions, undefined, "\t") + "\n", "utf-8");
}
export async function nextAuthenticationMethodId() {
const nextId = await readJson(nextAuthenticationMethodIdPath, 0);
await writeFile(nextAuthenticationMethodIdPath, String(nextId + 1), "utf-8");
return nextId;
}
export async function writeNextAuthenticationMethodId(nextId: number) {
await writeFile(nextAuthenticationMethodIdPath, String(nextId), "utf-8");
}
export async function readAuthenticationMethods() {
return readJson<ServerAuthenticationMethod[]>(authMethodPath, [])
}
export async function writeAuthenticationMethods(authMethods: ServerAuthenticationMethod[]) {
await writeFile(authMethodPath, JSON.stringify(authMethods, undefined, "\t") + "\n", "utf-8");
}

View file

@ -2,8 +2,10 @@
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { readUsers } from "~/server/database";
import { readUsers, type ServerSession } from "~/server/database";
import type { ApiAccount, ApiEvent } from "~/shared/types/api";
import { serverSessionToApi } from "./utils/session";
import { H3Event } from "h3";
function sendMessage(
stream: WritableStream<string>,
@ -31,18 +33,30 @@ function sendMessageAndClose(
;
}
const streams = new Map<WritableStream<string>, { sessionId?: number, accountId?: number }>();
const streams = new Map<WritableStream<string>, { sessionId?: number, accountId?: number, rotatesAtMs: number }>();
let keepaliveInterval: ReturnType<typeof setInterval> | null = null
export function addStream(stream: WritableStream<string>, sessionId?: number, accountId?: number) {
export async function addStream(
event: H3Event,
stream: WritableStream<string>,
session?: ServerSession,
) {
if (streams.size === 0) {
console.log("Starting keepalive")
keepaliveInterval = setInterval(sendKeepalive, 4000)
}
streams.set(stream, { sessionId, accountId });
// Produce a response immediately to avoid the reply waiting for content.
const message = `data: connected sid:${sessionId ?? '-'} aid:${accountId ?? '-'}\n\n`;
sendMessage(stream, message);
const runtimeConfig = useRuntimeConfig(event);
streams.set(stream, {
sessionId: session?.id,
accountId: session?.accountId,
rotatesAtMs: session?.rotatesAtMs ?? Date.now() + runtimeConfig.sessionRotatesTimeout * 1000,
});
// Produce a response immediately to avoid the reply waiting for content.
const update: ApiEvent = {
type: "connected",
session: session ? await serverSessionToApi(event, session) : undefined,
};
sendMessage(stream, `event: update\ndata: ${JSON.stringify(update)}\n\n`);
}
export function deleteStream(stream: WritableStream<string>) {
streams.delete(stream);
@ -119,6 +133,13 @@ export async function broadcastEvent(event: ApiEvent) {
if (!streams.size) {
return;
}
// Session expiry events cause the streams belonging to that session to be terminated
if (event.type === "session-expired") {
cancelSessionStreams(event.sessionId);
return;
}
const users = await readUsers();
for (const [stream, streamData] of streams) {
// Account events are specially handled and only sent to the user they belong to.
@ -130,7 +151,7 @@ export async function broadcastEvent(event: ApiEvent) {
} else {
let userType: ApiAccount["type"] | undefined;
if (streamData.accountId !== undefined) {
userType = users.find(a => a.id === streamData.accountId)?.type
userType = users.find(a => !a.deleted && a.id === streamData.accountId)?.type
}
const data = encodeEvent(event, userType)
sendMessage(stream, `id: ${id}\nevent: update\ndata: ${data}\n\n`);
@ -139,7 +160,12 @@ export async function broadcastEvent(event: ApiEvent) {
}
function sendKeepalive() {
for (const stream of streams.keys()) {
sendMessage(stream, ": keepalive\n");
const now = Date.now();
for (const [stream, streamData] of streams) {
if (streamData.rotatesAtMs > now) {
sendMessage(stream, ": keepalive\n");
} else {
sendMessageAndClose(stream, `data: cancelled\n\n`);
}
}
}

View file

@ -3,5 +3,8 @@
SPDX-License-Identifier: AGPL-3.0-or-later
*/
{
"extends": "../.nuxt/tsconfig.server.json"
"extends": "../.nuxt/tsconfig.server.json",
"compilerOptions": {
"verbatimModuleSyntax": true,
},
}

View file

@ -3,9 +3,18 @@
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { H3Event } from "h3";
import { nextSessionId, readSessions, readSubscriptions, type ServerSession, type ServerUser, writeSessions, writeSubscriptions } from "~/server/database";
const oneYearSeconds = 365 * 24 * 60 * 60;
import {
nextSessionId,
readSessions,
readSubscriptions,
readUsers,
type ServerSession,
type ServerUser,
writeSessions,
writeSubscriptions
} from "~/server/database";
import { broadcastEvent } from "../streams";
import type { ApiAuthenticationProvider, ApiSession } from "~/shared/types/api";
async function removeSessionSubscription(sessionId: number) {
const subscriptions = await readSubscriptions();
@ -20,9 +29,13 @@ async function clearServerSessionInternal(event: H3Event, sessions: ServerSessio
const existingSessionCookie = await getSignedCookie(event, "session");
if (existingSessionCookie) {
const sessionId = parseInt(existingSessionCookie, 10);
const sessionIndex = sessions.findIndex(session => session.id === sessionId);
if (sessionIndex !== -1) {
sessions.splice(sessionIndex, 1);
const session = sessions.find(session => session.id === sessionId);
if (session) {
session.expiresAtMs = Date.now();
broadcastEvent({
type: "session-expired",
sessionId,
});
await removeSessionSubscription(sessionId);
return true;
}
@ -38,50 +51,140 @@ export async function clearServerSession(event: H3Event) {
deleteCookie(event, "session");
}
export async function setServerSession(event: H3Event, account: ServerUser) {
export async function setServerSession(
event: H3Event,
account: ServerUser | undefined,
authenticationProvider?: ApiAuthenticationProvider,
authenticationSlug?: string,
authenticationName?: string,
) {
const sessions = await readSessions();
const runtimeConfig = useRuntimeConfig(event);
await clearServerSessionInternal(event, sessions);
const now = Date.now();
const newSession: ServerSession = {
account,
access: account?.type ?? "anonymous",
accountId: account?.id,
authenticationProvider,
authenticationSlug,
authenticationName,
rotatesAtMs: now + runtimeConfig.sessionRotatesTimeout * 1000,
discardAtMs: now + runtimeConfig.sessionDiscardTimeout * 1000,
id: await nextSessionId(),
};
sessions.push(newSession);
await writeSessions(sessions);
await setSignedCookie(event, "session", String(newSession.id), oneYearSeconds)
await setSignedCookie(event, "session", String(newSession.id), runtimeConfig.sessionDiscardTimeout)
return newSession;
}
export async function refreshServerSession(event: H3Event, session: ServerSession) {
await setSignedCookie(event, "session", String(session.id), oneYearSeconds)
async function rotateSession(event: H3Event, sessions: ServerSession[], session: ServerSession) {
const runtimeConfig = useRuntimeConfig(event);
const users = await readUsers();
const account = users.find(user => !user.deleted && user.id === session.accountId);
const now = Date.now();
const newSession: ServerSession = {
accountId: account?.id,
access: account?.type ?? "anonymous",
// Authentication provider is removed to avoid possibility of an infinite delay before using it.
rotatesAtMs: now + runtimeConfig.sessionRotatesTimeout * 1000,
discardAtMs: now + runtimeConfig.sessionDiscardTimeout * 1000,
id: await nextSessionId(),
};
session.successor = newSession.id;
session.expiresAtMs = Date.now() + 10 * 1000;
sessions.push(newSession);
await writeSessions(sessions);
await setSignedCookie(event, "session", String(newSession.id), runtimeConfig.sessionDiscardTimeout)
return newSession;
}
export async function getServerSession(event: H3Event) {
export async function getServerSession(event: H3Event, ignoreTaken: boolean) {
const sessionCookie = await getSignedCookie(event, "session");
if (sessionCookie) {
const sessionId = parseInt(sessionCookie, 10);
const sessions = await readSessions();
return sessions.find(session => session.id === sessionId);
const session = sessions.find(session => session.id === sessionId);
if (session) {
const nowMs = Date.now();
if (nowMs >= session.discardAtMs) {
return undefined;
}
if (session.expiresAtMs !== undefined && nowMs >= session.expiresAtMs) {
if (!ignoreTaken && session.successor !== undefined) {
throw createError({
statusCode: 403,
statusMessage: "Forbidden",
message: "Session has been taken by another agent.",
data: { code: "SESSION_TAKEN" },
});
}
return undefined;
}
if (nowMs >= session.rotatesAtMs && session.successor === undefined) {
return await rotateSession(event, sessions, session);
}
}
return session;
}
}
export async function requireServerSession(event: H3Event) {
const session = await getServerSession(event);
export async function requireServerSession(event: H3Event, message: string) {
const session = await getServerSession(event, false);
if (!session)
throw createError({
status: 401,
message: "Account session required",
statusCode: 401,
statusMessage: "Unauthorized",
message,
});
return session;
}
export async function requireServerSessionWithUser(event: H3Event) {
const message = "User session required";
const session = await requireServerSession(event, message);
const users = await readUsers();
const account = users.find(user => user.id === session.accountId);
if (session.accountId === undefined || !account || account.deleted)
throw createError({
statusCode: 401,
statusMessage: "Uauthorized",
message,
});
return { ...session, accountId: session.accountId };
}
export async function requireServerSessionWithAdmin(event: H3Event) {
const session = await requireServerSession(event);
if (session.account.type !== "admin") {
const message = "Admin session required";
const session = await requireServerSession(event, message);
const users = await readUsers();
const account = users.find(user => user.id === session.accountId);
if (session.access !== "admin" || account?.type !== "admin") {
throw createError({
statusCode: 403,
statusMessage: "Forbidden",
message,
});
}
return session;
return { ...session, accountId: session.accountId };
}
export async function serverSessionToApi(event: H3Event, session: ServerSession): Promise<ApiSession> {
const users = await readUsers();
const account = users.find(user => !user.deleted && user.id === session.accountId);
const subscriptions = await readSubscriptions();
const push = Boolean(
subscriptions.find(sub => sub.type === "push" && sub.sessionId === session.id)
);
return {
id: session.id,
account,
authenticationProvider: session.authenticationProvider,
authenticationName: session.authenticationName,
push,
};
}

View file

@ -25,7 +25,7 @@ export async function setSignedCookie(event: H3Event, name: string, value: strin
const secret = await useCookieSecret(event);
const signature = await crypto.subtle.sign("HMAC", secret, Buffer.from(`${name}=${value}`));
const cookie = `${value}.${Buffer.from(signature).toString("base64url")}`
setCookie(event, name, cookie, { httpOnly: true, secure: true, sameSite: true, maxAge });
setCookie(event, name, cookie, { httpOnly: true, secure: true, sameSite: "lax", maxAge });
}
export async function getSignedCookie(event: H3Event, name: string) {

View file

@ -67,9 +67,16 @@ export const apiSubscriptionSchema = z.object({
});
export type ApiSubscription = z.infer<typeof apiSubscriptionSchema>;
export type ApiAuthenticationProvider =
| "demo"
| "telegram"
;
export interface ApiSession {
id: Id,
account?: ApiAccount,
authenticationProvider?: ApiAuthenticationProvider,
authenticationName?: string,
push: boolean,
}
@ -149,12 +156,22 @@ export interface ApiAccountUpdate {
data: ApiAccount,
}
export interface ApiConnected {
type: "connected",
session?: ApiSession,
}
export interface ApiScheduleUpdate {
type: "schedule-update",
updatedFrom?: string,
data: ApiSchedule | ApiTombstone,
}
export interface ApiSessionExpired {
type: "session-expired",
sessionId: Id,
}
export interface ApiUserUpdate {
type: "user-update",
updatedFrom?: string,
@ -163,6 +180,8 @@ export interface ApiUserUpdate {
export type ApiEvent =
| ApiAccountUpdate
| ApiConnected
| ApiScheduleUpdate
| ApiSessionExpired
| ApiUserUpdate
;

20
shared/types/telegram.ts Normal file
View file

@ -0,0 +1,20 @@
/*
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { z } from "zod/v4-mini";
export const telegramAuthDataSchema = z.catchall(
z.object({
// These fields are pure speculation as the actual API is undocumented.
auth_date: z.number(),
first_name: z.optional(z.string()),
hash: z.string(),
id: z.number(),
last_name: z.optional(z.string()),
photo_url: z.optional(z.string()),
username: z.optional(z.string()),
}),
z.union([z.string(), z.number()]),
);
export type TelegramAuthData = z.infer<typeof telegramAuthDataSchema>;

View file

@ -4,7 +4,7 @@
*/
import { appendResponseHeader } from "h3";
import type { H3Event } from "h3";
import type { ApiAccount } from "~/shared/types/api";
import type { ApiAccount, ApiSession } from "~/shared/types/api";
const fetchSessionWithCookie = async (event?: H3Event) => {
// Client side
@ -26,6 +26,8 @@ const fetchSessionWithCookie = async (event?: H3Event) => {
export const useSessionStore = defineStore("session", () => {
const state = {
account: ref<ApiAccount>(),
authenticationProvider: ref<string>(),
authenticationName: ref<string>(),
id: ref<number>(),
push: ref<boolean>(false),
};
@ -33,18 +35,15 @@ export const useSessionStore = defineStore("session", () => {
const actions = {
async fetch(event?: H3Event) {
const session = await fetchSessionWithCookie(event)
actions.update(session);
},
update(session?: ApiSession) {
state.account.value = session?.account;
state.authenticationProvider.value = session?.authenticationProvider;
state.authenticationName.value = session?.authenticationName;
state.id.value = session?.id;
state.push.value = session?.push ?? false;
},
async logIn(name: string) {
const res = await $fetch.raw("/api/auth/login", {
method: "POST",
body: { name },
});
await actions.fetch();
return `/api/auth/login replied: ${res.status} ${res.statusText}`;
},
async logOut() {
try {
await $fetch.raw("/api/auth/session", {
@ -58,6 +57,13 @@ export const useSessionStore = defineStore("session", () => {
},
};
appEventSource?.addEventListener("update", (event) => {
if (event.data.type !== "connected") {
return;
}
actions.update(event.data.session);
});
return {
...state,
...actions,