User CRUD on backend and refactors

This commit is contained in:
2025-10-09 16:25:15 +02:00
parent 486ff6279d
commit 2f96b55205
7 changed files with 246 additions and 174 deletions

View File

@@ -1,8 +1,10 @@
import { config } from "backend/config";
import { BunRequest } from "bun";
import { SessionId, UserId } from "common";
import * as Body from "common/Body";
import { fetch } from "common/Fetch";
import { NotFound, Unauthenticated, User } from "common/the_api";
import { Context, DateTime, Duration, Effect, HashMap, HashSet, Layer, Option, pipe, Schema } from "effect";
import { Context, DateTime, Effect, HashMap, HashSet, Layer, Option, pipe, Redacted, Schema } from "effect";
import { constant } from "effect/Function";
import { sql } from "kysely";
import * as Database from "./Database";
@@ -11,16 +13,12 @@ export interface AuthenticationInterface {
readonly me: Effect.Effect<User, Unauthenticated>;
readonly logout: Effect.Effect<void>;
readonly sessionId: SessionId;
readonly getUser: (userId: UserId) => Effect.Effect<User, NotFound>;
}
export class Authentication extends Context.Tag("Authentication")<Authentication, AuthenticationInterface>() { }
export const OAUTH_SCOPE = "email openid profile";
export const REDIRECT_URI = config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/login` : "http://localhost:3000/login";
export const EXPIRATION_BUFFER = Duration.seconds(10);
export const SESSION_COOKIE_NAME = "sessionId";
export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect.gen(function* () {
@@ -51,8 +49,6 @@ export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect
const returning = [
"sessionId",
"codeVerifier",
"state",
"userId",
] as const;
@@ -71,8 +67,6 @@ export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect
const state = Object.freeze({
sessionId: session.sessionId,
codeVerifier: Option.fromNullable(session.codeVerifier),
state: Option.fromNullable(session.state),
userId: Option.fromNullable(session.userId),
});
@@ -92,67 +86,18 @@ export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect
.where("sessionId", "=", state.sessionId)
.$call(Database.execute),
sessionId: state.sessionId,
getUser: (userId) => pipe(
getUser(userId),
Effect.catchTag("NoSuchElementException", () => Effect.fail(NotFound.make())),
Effect.provideService(Database.Database, database),
),
});
}));
export const Test = (me: Option.Option<User>, users: HashMap.HashMap<UserId, User>) => Layer.sync(Authentication, constant(Object.freeze<AuthenticationInterface>({
export const Test = (me: Option.Option<User>) => Layer.sync(Authentication, constant(Object.freeze<AuthenticationInterface>({
me: Option.match(me, {
onNone: () => Effect.fail(Unauthenticated.make()),
onSome: (me) => Effect.succeed(me),
}),
logout: Effect.void,
sessionId: generateSessionId(),
getUser: (userId) => pipe(
users,
HashMap.get(userId),
Option.match({
onNone: () => Effect.fail(NotFound.make()),
onSome: Effect.succeed,
}),
),
})));
export const AccessTokenPayload = Schema.Struct({
aud: pipe(
Schema.String,
Schema.HashSet,
),
exp: Schema.Number,
iat: Schema.Number,
iss: Schema.String,
sub: UserId,
});
export const IdTokenPayload = Schema.Struct({
aud: pipe(
Schema.String,
Schema.HashSet,
),
exp: Schema.Number,
iat: Schema.Number,
iss: Schema.String,
sub: UserId,
name: Schema.String,
given_name: Schema.String,
family_name: Schema.String,
display_name: Schema.String,
preferred_username: Schema.String,
email: Schema.String,
email_verified: Schema.Boolean,
picture: Schema.String,
});
export type AccessTokenPayload = typeof AccessTokenPayload.Type;
export type IdTokenPayload = typeof IdTokenPayload.Type;
function generateCodeVerifier(byteLength: number = 32) {
const codeVerifierBytes = new Uint8Array(byteLength);
crypto.getRandomValues(codeVerifierBytes);
@@ -183,7 +128,7 @@ function generateRandomState(byteLength: number = 32): string {
return state;
}
const getUser = (userId: UserId) => Effect.gen(function* () {
export const getUser = (userId: UserId) => Effect.gen(function* () {
const database = yield* Database.Database;
const user = yield* database
@@ -224,12 +169,24 @@ const getOrAddUser = (userId: UserId) => pipe(
)),
);
export const upsertUser = (idTokenPayload: IdTokenPayload) => Effect.gen(function* () {
const upsertUser = (idTokenPayload: { readonly [_: string]: unknown }) => Effect.gen(function* () {
const database = yield* Database.Database;
const userId = idTokenPayload.sub;
const displayName = idTokenPayload.display_name;
const avatarUrl = idTokenPayload.picture;
const userId = config.OAUTH_USER_ID_CLAIM in idTokenPayload
? UserId.make(String(idTokenPayload[config.OAUTH_USER_ID_CLAIM]))
: null;
const displayName = config.OAUTH_DISPLAY_NAME_CLAIM in idTokenPayload
? String(idTokenPayload[config.OAUTH_DISPLAY_NAME_CLAIM])
: null;
const avatarUrl = config.OAUTH_AVATAR_URL_CLAIM in idTokenPayload
? String(idTokenPayload[config.OAUTH_AVATAR_URL_CLAIM])
: null;
if (userId === null) {
return yield* Effect.fail(Unauthenticated.make());
}
const user = yield* database
.insertInto("User")
@@ -259,17 +216,58 @@ export const upsertUser = (idTokenPayload: IdTokenPayload) => Effect.gen(functio
});
});
export const getJwtTokenPayload = <A, I, R>(schema: Schema.Schema<A, I, R>) => {
const decoder = Schema.decodeUnknown(schema);
return (token: string) => {
const json = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf-8"));
return pipe(
decoder(json),
Effect.orDie,
);
};
const getJwtTokenPayload = (token: string) => {
const json = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf-8"));
return json as { readonly [_: string]: unknown };
};
export const getAndProcessIdToken = (code: string | null, state: string | null) => Effect.gen(function* () {
const { sessionId } = yield* Authentication;
const db = yield* Database.Database;
const session = yield* db
.selectFrom("Session")
.select(["codeVerifier"])
.where("sessionId", "=", sessionId)
.$call(Database.executeTakeFirst);
const codeVerifier = Option.fromNullable(session.codeVerifier);
if (code !== null && state !== null && Option.isSome(codeVerifier)) {
const idTokenPayload = yield* pipe(
fetch(config.OAUTH_TOKEN_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
"client_id": config.OAUTH_CLIENT_ID,
"code": code,
"redirect_uri": REDIRECT_URI,
"grant_type": "authorization_code",
"code_verifier": codeVerifier.value,
"client_secret": Redacted.value(config.OAUTH_CLIENT_SECRET),
}).toString(),
}),
Effect.flatMap(Body.json),
Effect.map((body) => (body as { id_token: string }).id_token),
Effect.map(getJwtTokenPayload),
);
const { userId } = yield* upsertUser(idTokenPayload);
yield* db
.updateTable("Session")
.set({
codeVerifier: null,
state: null,
userId,
})
.where("sessionId", "=", sessionId)
.$call(Database.execute);
}
});
export const makeAuthorizationUrl = (sessionId: SessionId) => Effect.gen(function* () {
const database = yield* Database.Database;