tangled
alpha
login
or
join now
dbushell.com
/
attic.social
6
fork
atom
Attic is a cozy space with lofty ambitions.
attic.social
6
fork
atom
overview
issues
pulls
pipelines
sveltekit session
dbushell.com
2 weeks ago
65509e82
f5dc2e34
verified
This commit was signed with the committer's
known signature
.
dbushell.com
SSH Key Fingerprint:
SHA256:Sj5AfJ6VbC0PEnnQD2kGGEiGFwHdFBS/ypN5oifzzFI=
+260
-68
14 changed files
expand all
collapse all
unified
split
src
app.d.ts
hooks.server.ts
lib
assets
favicon.svg
index.ts
server
crypto.ts
oauth.ts
session.ts
routes
+layout.server.ts
+layout.svelte
+page.server.ts
+page.svelte
oauth
callback
+server.ts
static
robots.txt
svelte.config.js
+14
-1
src/app.d.ts
···
1
1
+
import type { OAuthSession } from "@atcute/oauth-node-client";
2
2
+
import type { Client } from "@atcute/client";
3
3
+
import type { Did, Handle } from "@atcute/lexicons";
4
4
+
1
5
// See https://svelte.dev/docs/kit/types#app.d.ts
2
6
// for information about these interfaces
3
7
declare global {
4
8
namespace App {
5
9
// interface Error {}
6
6
-
// interface Locals {}
10
10
+
interface Locals {
11
11
+
user?: {
12
12
+
client: Client;
13
13
+
session: OAuthSession;
14
14
+
did: Did;
15
15
+
handle: Handle;
16
16
+
displayName: string;
17
17
+
avatar: string;
18
18
+
};
19
19
+
}
7
20
// interface PageData {}
8
21
// interface PageState {}
9
22
// interface Platform {}
+22
src/hooks.server.ts
···
1
1
+
import type { Handle } from "@sveltejs/kit";
2
2
+
import { dev } from "$app/environment";
3
3
+
import { sequence } from "@sveltejs/kit/hooks";
4
4
+
import { restoreSession } from "$lib/server/session.ts";
5
5
+
6
6
+
/**
7
7
+
* {@link https://svelte.dev/docs/cli/devtools-json}
8
8
+
*/
9
9
+
const devHandle: Handle = ({ event, resolve }) => {
10
10
+
const path = "/.well-known/appspecific/com.chrome.devtools.json";
11
11
+
if (dev && event.url.pathname === path) {
12
12
+
return new Response(null, { status: 404 });
13
13
+
}
14
14
+
return resolve(event);
15
15
+
};
16
16
+
17
17
+
export const defaultHandle: Handle = async ({ event, resolve }) => {
18
18
+
await restoreSession(event);
19
19
+
return resolve(event);
20
20
+
};
21
21
+
22
22
+
export const handle: Handle = sequence(devHandle, defaultHandle);
-1
src/lib/assets/favicon.svg
···
1
1
-
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
-1
src/lib/index.ts
···
1
1
-
// place files you want to import through the `$lib` alias in this folder.
+59
src/lib/server/crypto.ts
···
1
1
+
import { Buffer } from "node:buffer";
2
2
+
3
3
+
const { crypto, crypto: { subtle } } = globalThis;
4
4
+
5
5
+
export const sha256Hash = (value: string): Promise<ArrayBuffer> =>
6
6
+
subtle.digest("SHA-256", new TextEncoder().encode(value));
7
7
+
8
8
+
export const randomIV = (length: number): Uint8Array<ArrayBuffer> =>
9
9
+
crypto.getRandomValues(new Uint8Array(length));
10
10
+
11
11
+
export const importKey = async (password: string): Promise<CryptoKey> => {
12
12
+
const key = await subtle.importKey(
13
13
+
"raw",
14
14
+
await sha256Hash(password),
15
15
+
{ name: "AES-GCM" },
16
16
+
false,
17
17
+
["encrypt", "decrypt"],
18
18
+
);
19
19
+
return key;
20
20
+
};
21
21
+
22
22
+
export const encryptText = async (
23
23
+
value: string,
24
24
+
key: CryptoKey | string,
25
25
+
): Promise<string> => {
26
26
+
const theKey = key instanceof CryptoKey ? key : await importKey(key);
27
27
+
const iv = randomIV(12);
28
28
+
const decryptedValue = new TextEncoder().encode(value);
29
29
+
const encryptedValue = await subtle.encrypt(
30
30
+
{
31
31
+
name: "AES-GCM",
32
32
+
iv,
33
33
+
},
34
34
+
theKey,
35
35
+
decryptedValue,
36
36
+
);
37
37
+
const ivBase64 = Buffer.from(iv).toString("base64");
38
38
+
const encryptedBase64 = Buffer.from(encryptedValue).toString("base64");
39
39
+
return `${ivBase64}:${encryptedBase64}`;
40
40
+
};
41
41
+
42
42
+
export const decryptText = async (
43
43
+
value: string,
44
44
+
key: CryptoKey | string,
45
45
+
): Promise<string> => {
46
46
+
const base64 = value.split(":");
47
47
+
const iv = Buffer.from(base64[0], "base64");
48
48
+
const encryptedValue = Buffer.from(base64[1], "base64");
49
49
+
const theKey = key instanceof CryptoKey ? key : await importKey(key);
50
50
+
const decryptedValue = await subtle.decrypt(
51
51
+
{
52
52
+
name: "AES-GCM",
53
53
+
iv,
54
54
+
},
55
55
+
theKey,
56
56
+
encryptedValue,
57
57
+
);
58
58
+
return new TextDecoder().decode(decryptedValue);
59
59
+
};
+1
-1
src/lib/server/oauth.ts
···
19
19
/**
20
20
* {@link https://github.com/mary-ext/atcute/tree/trunk/packages/oauth/node-client-public-example}
21
21
*/
22
22
-
export function createOAuthClient() {
22
22
+
export function createOAuthClient(): OAuthClient {
23
23
// [TODO] dynamic hostname/port
24
24
const redirectUri = `http://127.0.0.1:5173/oauth/callback`;
25
25
+56
src/lib/server/session.ts
···
1
1
+
import type { RequestEvent } from "@sveltejs/kit";
2
2
+
import { Client } from "@atcute/client";
3
3
+
import { isDid, isHandle } from "@atcute/lexicons/syntax";
4
4
+
import { oAuthClient } from "$lib/server/oauth.ts";
5
5
+
import { decryptText } from "./crypto.ts";
6
6
+
import { env } from "$env/dynamic/private";
7
7
+
8
8
+
export const destroySession = async (event: RequestEvent): Promise<void> => {
9
9
+
event.cookies.delete("atproto_session", { path: "/" });
10
10
+
if (event.locals.user === undefined) {
11
11
+
return;
12
12
+
}
13
13
+
try {
14
14
+
// await event.locals.user.session.signOut();
15
15
+
await oAuthClient.revoke(event.locals.user.did);
16
16
+
} catch {
17
17
+
// Do nothing?
18
18
+
}
19
19
+
};
20
20
+
21
21
+
export const restoreSession = async (event: RequestEvent): Promise<void> => {
22
22
+
const { cookies } = event;
23
23
+
// Read the cookie
24
24
+
const encrypted = cookies.get("atproto_session");
25
25
+
if (encrypted === undefined) {
26
26
+
return;
27
27
+
}
28
28
+
// Parse and validate or delete
29
29
+
let data;
30
30
+
try {
31
31
+
const decrypted = await decryptText(encrypted, env.PRIVATE_COOKIE_KEY);
32
32
+
data = JSON.parse(decrypted);
33
33
+
} catch {
34
34
+
cookies.delete("atproto_session", { path: "/" });
35
35
+
return;
36
36
+
}
37
37
+
// [TODO] validate data type?
38
38
+
try {
39
39
+
if (
40
40
+
isDid(data.did) === false ||
41
41
+
isHandle(data.handle) === false
42
42
+
) {
43
43
+
throw new Error();
44
44
+
}
45
45
+
const session = await oAuthClient.restore(data.did);
46
46
+
const client = new Client({ handler: session });
47
47
+
event.locals.user = {
48
48
+
...data,
49
49
+
client,
50
50
+
session,
51
51
+
};
52
52
+
} catch {
53
53
+
cookies.delete("atproto_session", { path: "/" });
54
54
+
return;
55
55
+
}
56
56
+
};
+15
src/routes/+layout.server.ts
···
1
1
+
import type { LayoutServerLoad } from "./$types";
2
2
+
3
3
+
export const load: LayoutServerLoad = (event) => {
4
4
+
let user = undefined;
5
5
+
if (event.locals.user) {
6
6
+
user = {
7
7
+
handle: event.locals.user.handle,
8
8
+
displayName: event.locals.user.displayName,
9
9
+
avatar: event.locals.user.avatar,
10
10
+
};
11
11
+
}
12
12
+
return {
13
13
+
user,
14
14
+
};
15
15
+
};
-6
src/routes/+layout.svelte
···
1
1
<script lang="ts">
2
2
-
import favicon from "$lib/assets/favicon.svg";
3
3
-
4
2
let { children } = $props();
5
3
</script>
6
6
-
7
7
-
<svelte:head>
8
8
-
<link rel="icon" href={favicon} />
9
9
-
</svelte:head>
10
4
11
5
{@render children()}
+19
-21
src/routes/+page.server.ts
···
1
1
import { type Actions, fail, redirect } from "@sveltejs/kit";
2
2
import { isActorIdentifier } from "@atcute/lexicons/syntax";
3
3
-
// import crypto from "node:crypto";
4
3
import { oAuthClient } from "$lib/server/oauth.ts";
5
5
-
// import { dev } from "$app/environment";
4
4
+
import { destroySession } from "../lib/server/session.ts";
5
5
+
import { dev } from "$app/environment";
6
6
7
7
export const actions = {
8
8
-
login: async ({ request }) => {
8
8
+
logout: async (event) => {
9
9
+
await destroySession(event);
10
10
+
redirect(303, "/");
11
11
+
},
12
12
+
login: async ({ cookies, request }) => {
9
13
const formData = await request.formData();
10
14
const handle = formData.get("handle");
11
11
-
12
15
if (isActorIdentifier(handle) === false) {
13
16
return fail(400, { handle, invalid: true });
14
17
}
···
17
20
"type": "account",
18
21
identifier: handle,
19
22
},
20
20
-
// scope: [
21
21
-
// "atproto",
22
22
-
// ].join(" "),
23
23
});
24
24
-
// [TODO] delete / handled by @atcute?
25
25
-
// cookies.set(
26
26
-
// "atproto_oauth_request",
27
27
-
// crypto.createHash("sha256")
28
28
-
// .update(stateId, "utf8")
29
29
-
// .digest("hex"),
30
30
-
// {
31
31
-
// httpOnly: true,
32
32
-
// maxAge: 60 * 5,
33
33
-
// path: "/",
34
34
-
// secure: !dev,
35
35
-
// sameSite: "lax",
36
36
-
// },
37
37
-
// );
24
24
+
// [TODO] encrypt handle?
25
25
+
cookies.set(
26
26
+
"atproto_handle",
27
27
+
handle,
28
28
+
{
29
29
+
httpOnly: true,
30
30
+
maxAge: 60 * 5,
31
31
+
path: "/",
32
32
+
sameSite: "lax",
33
33
+
secure: !dev,
34
34
+
},
35
35
+
);
38
36
redirect(303, url);
39
37
},
40
38
} satisfies Actions;
+17
-9
src/routes/+page.svelte
···
5
5
let handle = $derived(form?.handle ?? "");
6
6
</script>
7
7
8
8
-
<form method="POST" action="?/login">
9
9
-
<h2>Login</h2>
10
10
-
{#if form?.invalid}
11
11
-
<p><strong>Invalid handle</strong></p>
12
12
-
{/if}
13
13
-
<label for="handle">Handle</label>
14
14
-
<input type="text" id="handle" name="handle" bind:value={handle} />
15
15
-
<button type="submit">Login</button>
16
16
-
</form>
8
8
+
{#if data.user}
9
9
+
<h2>Hello, {data.user.displayName}!</h2>
10
10
+
<form method="POST" action="?/logout">
11
11
+
<button type="submit">Logout</button>
12
12
+
</form>
13
13
+
{:else}
14
14
+
<form method="POST" action="?/login">
15
15
+
<h2>Connect</h2>
16
16
+
<p>Connect with your Bluesky / Atmosphere account.</p>
17
17
+
{#if form?.invalid}
18
18
+
<p><strong>Invalid handle</strong></p>
19
19
+
{/if}
20
20
+
<label for="handle">Handle</label>
21
21
+
<input type="text" id="handle" name="handle" bind:value={handle} />
22
22
+
<button type="submit">Login</button>
23
23
+
</form>
24
24
+
{/if}
+52
-26
src/routes/oauth/callback/+server.ts
···
1
1
+
import type { RequestHandler } from "./$types";
2
2
+
import type { OAuthSession } from "@atcute/oauth-node-client";
1
3
import { AppBskyActorGetProfile } from "@atcute/bluesky";
2
4
import { Client, ok } from "@atcute/client";
3
5
import { redirect } from "@sveltejs/kit";
4
6
import { oAuthClient } from "$lib/server/oauth.ts";
5
5
-
import type { RequestHandler } from "./$types";
6
6
-
import type { OAuthSession } from "@atcute/oauth-node-client";
7
7
+
import { encryptText } from "$lib/server/crypto.ts";
8
8
+
import { env } from "$env/dynamic/private";
9
9
+
import { dev } from "$app/environment";
7
10
8
11
export const GET: RequestHandler = async (event) => {
9
12
const { url, cookies } = event;
10
13
11
14
// [TODO] delete / handled by @atcute?
12
12
-
// const state = cookies.get("atproto_oauth_request");
13
13
-
// if (state === undefined) {
14
14
-
// return redirect(303, "/?error=expired");
15
15
-
// }
16
16
-
// cookies.delete(
17
17
-
// "atproto_oauth_request",
18
18
-
// { path: "/" },
19
19
-
// );
20
20
-
21
21
-
console.log(...url.searchParams);
15
15
+
const handle = cookies.get("atproto_handle");
16
16
+
if (handle === undefined) {
17
17
+
return redirect(303, "/?error=expired");
18
18
+
}
19
19
+
cookies.delete("atproto_handle", { path: "/" });
22
20
23
21
let session: OAuthSession;
24
22
try {
···
27
25
console.error(err);
28
26
redirect(303, "/?error=session");
29
27
}
30
30
-
console.log(session);
31
28
32
32
-
const rpc = new Client({ handler: session });
33
33
-
const profile = await ok(
34
34
-
rpc.call(AppBskyActorGetProfile, {
35
35
-
params: { actor: session.did },
36
36
-
}),
37
37
-
);
29
29
+
// [TODO] remember handle from login form
30
30
+
const data = {
31
31
+
handle,
32
32
+
did: session.did,
33
33
+
displayName: "",
34
34
+
avatar: "",
35
35
+
};
38
36
39
39
-
console.log(profile);
37
37
+
try {
38
38
+
const rpc = new Client({ handler: session });
39
39
+
const profile = await ok(
40
40
+
rpc.call(AppBskyActorGetProfile, {
41
41
+
params: { actor: session.did },
42
42
+
}),
43
43
+
);
44
44
+
// if (profile.handle) {
45
45
+
// data.handle = profile.handle;
46
46
+
// }
47
47
+
if (profile.displayName) {
48
48
+
data.displayName = profile.displayName;
49
49
+
}
50
50
+
if (profile.avatar) {
51
51
+
data.avatar = profile.avatar;
52
52
+
}
53
53
+
} catch {
54
54
+
// No Bluesky account?
55
55
+
}
40
56
41
41
-
/**
42
42
-
* [TODO]
43
43
-
* parse session params
44
44
-
* encrypt session cookie
45
45
-
* redirect to?
46
46
-
*/
57
57
+
const encrypted = await encryptText(
58
58
+
JSON.stringify(data),
59
59
+
env.PRIVATE_COOKIE_KEY,
60
60
+
);
61
61
+
62
62
+
cookies.set(
63
63
+
"atproto_session",
64
64
+
encrypted,
65
65
+
{
66
66
+
httpOnly: true,
67
67
+
maxAge: 60 * 60 * 24,
68
68
+
path: "/",
69
69
+
sameSite: "lax",
70
70
+
secure: !dev,
71
71
+
},
72
72
+
);
47
73
48
74
redirect(303, "/?success");
49
75
};
+1
-2
static/robots.txt
···
1
1
-
# allow crawling everything by default
2
1
User-agent: *
3
3
-
Disallow:
2
2
+
Disallow: /
+4
svelte.config.js
···
10
10
alias: {
11
11
$lib: "src/lib",
12
12
},
13
13
+
env: {
14
14
+
publicPrefix: "PUBLIC",
15
15
+
privatePrefix: "PRIVATE",
16
16
+
},
13
17
},
14
18
};
15
19