Switch to internally managed roles, adapt frontend

This commit is contained in:
2025-10-07 02:04:35 +02:00
parent dc0ec5c635
commit 90729736ca
11 changed files with 248 additions and 351 deletions

View File

@@ -1,31 +1,28 @@
import { config } from "backend/config";
import { BunRequest } from "bun";
import { SessionId, UserId } from "common";
import { fetch, FetchError } from "common/Fetch";
import { Me, NotFound, Other, Role, Unauthenticated } from "common/the_api";
import { Context, DateTime, Duration, Effect, HashMap, HashSet, Layer, Option, pipe, Redacted, Schema } from "effect";
import { NotFound, Unauthenticated, User } from "common/the_api";
import { Context, DateTime, Duration, Effect, HashMap, HashSet, Layer, Option, pipe, Schema } from "effect";
import { constant } from "effect/Function";
import { sql } from "kysely";
import * as Database from "./Database";
export interface AuthenticationInterface {
readonly me: Effect.Effect<Me, Unauthenticated>;
readonly me: Effect.Effect<User, Unauthenticated>;
readonly logout: Effect.Effect<void>;
readonly sessionId: SessionId;
readonly getUser: (userId: UserId) => Effect.Effect<Other, FetchError | Unauthenticated | NotFound>;
readonly getUser: (userId: UserId) => Effect.Effect<User, NotFound>;
}
export class Authentication extends Context.Tag("Authentication")<Authentication, AuthenticationInterface>() { }
export const OAUTH_SCOPE = "email openid profile offline_access";
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";
const ALL_ROLES = HashSet.fromIterable(Object.values(Role));
export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect.gen(function* () {
const database = yield* Database.Database;
@@ -54,11 +51,9 @@ export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect
const returning = [
"sessionId",
"accessToken",
"codeVerifier",
"idToken",
"refreshToken",
"state",
"userId",
] as const;
const session = yield* database
@@ -74,92 +69,52 @@ export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect
.$call(Database.executeTakeFirstOrDie)
));
const { accessToken, idToken, refreshToken } = yield* revalidateTokens({
accessToken: Option.fromNullable(session.accessToken),
idToken: Option.fromNullable(session.idToken),
refreshToken: Option.fromNullable(session.refreshToken),
});
yield* database
.updateTable("Session")
.set({
accessToken: pipe(
accessToken,
Option.map((at) => at.token),
Option.getOrNull,
),
idToken: pipe(
idToken,
Option.map((it) => it.token),
Option.getOrNull,
),
refreshToken: Option.getOrNull(refreshToken),
})
.$call(Database.execute);
const state = Object.freeze({
sessionId: session.sessionId,
accessToken,
idToken,
refreshToken,
codeVerifier: Option.fromNullable(session.codeVerifier),
state: Option.fromNullable(session.state),
userId: Option.fromNullable(session.userId),
});
return Object.freeze<AuthenticationInterface>({
me: pipe(
state.idToken,
Option.map(({ payload }) => {
const userData: Me = Object.freeze<Me>({
userId: payload.sub,
displayName: payload.display_name,
roles: ALL_ROLES,
});
return userData;
}),
Option.match({
onNone: () => Effect.fail(Unauthenticated.make()),
onSome: Effect.succeed,
}),
state.userId,
Effect.flatMap(getOrAddUser),
Effect.catchTag("NoSuchElementException", () => Effect.fail(Unauthenticated.make())),
Effect.provideService(Database.Database, database),
),
logout: database
.updateTable("Session")
.set({
accessToken: null,
codeVerifier: null,
expiresAt: sql`datetime('now', '+7 days')`,
idToken: null,
refreshToken: null,
state: null,
userId: null,
})
.where("sessionId", "=", state.sessionId)
.$call(Database.execute),
sessionId: state.sessionId,
getUser: (userId) => pipe(
getDisplayName(userId),
Effect.map((displayName) => Object.freeze<Other>({ userId, displayName })),
getUser(userId),
Effect.catchTag("NoSuchElementException", () => Effect.fail(NotFound.make())),
Effect.provideService(Database.Database, database),
),
});
}));
export const Test = (me: Option.Option<Me>, other: HashMap.HashMap<UserId, Other>) => Layer.sync(Authentication, constant(Object.freeze<AuthenticationInterface>({
export const Test = (me: Option.Option<User>, users: HashMap.HashMap<UserId, 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: Option.match(me, {
onNone: () => constant(Effect.fail(Unauthenticated.make())),
onSome: () => (userId) => pipe(
other,
HashMap.get(userId),
Option.match({
onNone: () => Effect.fail(NotFound.make()),
onSome: Effect.succeed,
}),
),
}),
getUser: (userId) => pipe(
users,
HashMap.get(userId),
Option.match({
onNone: () => Effect.fail(NotFound.make()),
onSome: Effect.succeed,
}),
),
})));
export const AccessTokenPayload = Schema.Struct({
@@ -167,8 +122,8 @@ export const AccessTokenPayload = Schema.Struct({
Schema.String,
Schema.HashSet,
),
exp: Schema.DateTimeUtc,
iat: Schema.DateTimeUtc,
exp: Schema.Number,
iat: Schema.Number,
iss: Schema.String,
sub: UserId,
});
@@ -178,8 +133,8 @@ export const IdTokenPayload = Schema.Struct({
Schema.String,
Schema.HashSet,
),
exp: Schema.DateTimeUtc,
iat: Schema.DateTimeUtc,
exp: Schema.Number,
iat: Schema.Number,
iss: Schema.String,
sub: UserId,
@@ -228,7 +183,83 @@ function generateRandomState(byteLength: number = 32): string {
return state;
}
const getJwtTokenPayload = <A, I, R>(schema: Schema.Schema<A, I, R>) => {
const getUser = (userId: UserId) => Effect.gen(function* () {
const database = yield* Database.Database;
const user = yield* database
.selectFrom("User")
.selectAll()
.where("userId", "=", userId)
.$call(Database.executeTakeFirst);
const roles = yield* database
.selectFrom("UserRole")
.select("role")
.where("userId", "=", userId)
.$call(Database.execute);
return Object.freeze<User>({
userId,
displayName: Option.fromNullable(user.displayName),
avatarUrl: Option.fromNullable(user.avatarUrl),
roles: HashSet.fromIterable(roles.map(({ role }) => role)),
});
});
const getOrAddUser = (userId: UserId) => pipe(
getUser(userId),
Effect.catchTag("NoSuchElementException", () => pipe(
Database.Database,
Effect.flatMap((database) => database
.insertInto("User")
.values({ userId })
.$call(Database.execute)
),
Effect.as(Object.freeze<User>({
userId,
displayName: Option.none(),
avatarUrl: Option.none(),
roles: HashSet.empty(),
})),
)),
);
export const upsertUser = (idTokenPayload: IdTokenPayload) => Effect.gen(function* () {
const database = yield* Database.Database;
const userId = idTokenPayload.sub;
const displayName = idTokenPayload.display_name;
const avatarUrl = idTokenPayload.picture;
const user = yield* database
.insertInto("User")
.values({
userId,
displayName,
avatarUrl,
})
.returningAll()
.onConflict((oc) => oc
.column("userId")
.doUpdateSet({ displayName, avatarUrl })
)
.$call(Database.executeTakeFirstOrDie);
const roles = yield* database
.selectFrom("UserRole")
.select("role")
.where("userId", "=", userId)
.$call(Database.execute);
return Object.freeze<User>({
userId,
displayName: Option.fromNullable(user.displayName),
avatarUrl: Option.fromNullable(user.avatarUrl),
roles: HashSet.fromIterable(roles.map(({ role }) => role)),
});
});
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"));
@@ -239,23 +270,6 @@ const getJwtTokenPayload = <A, I, R>(schema: Schema.Schema<A, I, R>) => {
};
};
const getDisplayName = (userId: UserId) => Effect.gen(function* () {
const url = new URL(`/api/users/${userId}`, config.POCKET_ID_API_ORIGIN);
const res = yield* fetch(url, {
headers: {
"X-API-KEY": config.POCKET_ID_API_KEY,
},
});
if (!res.ok) {
return yield* Effect.fail(NotFound.make());
}
const json = yield* Effect.promise(() => res.json());
const displayName: string = json.displayName;
return displayName;
});
export const makeAuthorizationUrl = (sessionId: SessionId) => Effect.gen(function* () {
const database = yield* Database.Database;
@@ -267,9 +281,6 @@ export const makeAuthorizationUrl = (sessionId: SessionId) => Effect.gen(functio
.set({
codeVerifier,
state,
accessToken: null,
idToken: null,
refreshToken: null,
})
.where("sessionId", "=", sessionId)
.$call(Database.execute);
@@ -286,112 +297,3 @@ export const makeAuthorizationUrl = (sessionId: SessionId) => Effect.gen(functio
return url.toString();
});
namespace revaildateTokens {
export interface Args {
readonly accessToken: Option.Option<string>;
readonly idToken: Option.Option<string>;
readonly refreshToken: Option.Option<string>;
}
export interface Result {
readonly accessToken: Option.Option<{
readonly token: string,
readonly payload: AccessTokenPayload,
}>;
readonly idToken: Option.Option<{
readonly token: string,
readonly payload: IdTokenPayload,
}>;
readonly refreshToken: Option.Option<string>;
}
}
const revalidateTokens = ({ accessToken, idToken, refreshToken }: revaildateTokens.Args) => Effect.gen(function* () {
const accessTokenPayload = yield* pipe(
accessToken,
Effect.transposeMapOption(getJwtTokenPayload(AccessTokenPayload)),
);
const idTokenPayload = yield* pipe(
idToken,
Effect.transposeMapOption(getJwtTokenPayload(IdTokenPayload)),
);
const expirationThreshold = DateTime.addDuration(
DateTime.unsafeNow(),
EXPIRATION_BUFFER,
);
// Token expired or missing
if (Option.match(accessTokenPayload, {
onNone: constant(false),
onSome: (atp) => DateTime.greaterThan(expirationThreshold, atp.exp),
}) || Option.match(idTokenPayload, {
onNone: constant(false),
onSome: (itp) => DateTime.greaterThan(expirationThreshold, itp.exp),
})) {
accessToken = Option.none();
idToken = Option.none();
// try refreshing
if (Option.isSome(refreshToken)) {
const refreshTokenValue = refreshToken.value;
const res = yield* fetch(config.OAUTH_TOKEN_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
"client_id": config.CLIENT_ID,
"grant_type": "refresh_token",
"refresh_token": refreshTokenValue,
"client_secret": Redacted.value(config.CLIENT_SECRET),
}).toString(),
});
const json: {
access_token: string,
refresh_token: string,
id_token: string,
} = yield* Effect.promise(() => res.json());
accessToken = Option.some(json.access_token);
idToken = Option.some(json.id_token);
refreshToken = Option.some(json.refresh_token);
}
}
const it = yield* pipe(
idToken,
Option.map((it) => pipe(
Effect.Do,
Effect.let("token", () => it),
Effect.bind("payload", ({ token }) => getJwtTokenPayload(IdTokenPayload)(token)),
)),
Effect.transposeOption,
Effect.map(Option.map((it) => Object.freeze(it))),
);
const at = yield* pipe(
accessToken,
Option.map((at) => pipe(
Effect.Do,
Effect.let("token", () => at),
Effect.bind("payload", ({ token }) => getJwtTokenPayload(AccessTokenPayload)(token)),
)),
Effect.transposeOption,
Effect.map(Option.map((at) => Object.freeze(at))),
);
const res: revaildateTokens.Result = Object.freeze<revaildateTokens.Result>({
accessToken: at,
idToken: it,
refreshToken,
});
return res;
});

View File

@@ -1,5 +1,6 @@
import { Database as BunSqliteDatabase } from "bun:sqlite";
import { AttachmentId, PieceId, RepertoireId, RequestId, SessionId, Sha256, UserId } from "common";
import { AttachmentId, PieceId, RepertoireId, SessionId, Sha256, UserId } from "common";
import { Role } from "common/the_api";
import { Cause, Context, Effect, Either, HashSet, Layer, pipe, Runtime } from "effect";
import { ColumnType, CompiledQuery, CreateTableBuilder, Insertable, Kysely, Selectable, Transaction } from "kysely";
import { BunSqliteDialect } from "kysely-bun-sqlite";
@@ -14,6 +15,8 @@ export interface DatabaseSchema {
Repertoire: RepertoireTable;
RepertoireEntry: RepertoireEntryTable;
Session: SessionTable;
User: UserTable;
UserRole: UserRoleTable;
sqlite_schema: SqliteSchemaTable;
}
@@ -73,9 +76,7 @@ export interface RepertoireEntryTable extends RepertoireEntryData {
export interface SessionData {
state: string | null;
codeVerifier: string | null;
accessToken: string | null;
idToken: string | null;
refreshToken: string | null;
userId: UserId | null;
}
export interface SessionTable extends SessionData {
@@ -83,6 +84,17 @@ export interface SessionTable extends SessionData {
expiresAt: string;
}
export interface UserTable {
userId: ColumnType<UserId, UserId, never>;
displayName: string | null;
avatarUrl: string | null;
}
export interface UserRoleTable {
userId: ColumnType<UserId, UserId, never>;
role: ColumnType<Role, Role, never>;
}
export interface SqliteSchemaTable {
type: ColumnType<string, never, never>;
name: ColumnType<string, never, never>;
@@ -98,6 +110,8 @@ export type Piece = Selectable<PieceTable>;
export type Repertoire = Selectable<RepertoireTable>;
export type RepertoireEntry = Selectable<RepertoireEntryTable>;
export type Session = Selectable<SessionTable>;
export type User = Selectable<UserTable>;
export type UserRole = Selectable<UserRoleTable>;
export type SqliteSchema = Selectable<SqliteSchemaTable>;
// --- MARK: EFFECT LAYERS -----------------------------------------------------
@@ -196,15 +210,6 @@ const initDatabase = (filename: string) => Effect.gen(function* () {
.addColumn("data", "blob", (c) => c.notNull())
.$call(execute);
yield* db.schema
.createTable("User")
.ifNotExists()
.addColumn("userId", "text", (c) => c.notNull().primaryKey())
.addColumn("username", "text", (c) => c.notNull().unique())
.addColumn("password", "text", (c) => c.notNull())
.addColumn("admin", "boolean", (c) => c.notNull())
.$call(execute);
yield* db.schema
.createTable("Piece")
.ifNotExists()
@@ -253,12 +258,18 @@ const initDatabase = (filename: string) => Effect.gen(function* () {
.addColumn("sessionId", "text", (c) => c.notNull().primaryKey())
.addColumn("state", "text")
.addColumn("codeVerifier", "text")
.addColumn("accessToken", "text")
.addColumn("idToken", "text")
.addColumn("refreshToken", "text")
.addColumn("userId", "text")
.addColumn("expiresAt", "text", (c) => c.notNull())
.$call(execute);
yield* db.schema
.createTable("User")
.ifNotExists()
.addColumn("userId", "text", (c) => c.notNull().primaryKey())
.addColumn("displayName", "text")
.addColumn("avatarUrl", "text")
.$call(execute);
yield* db.schema
.createTable("Attachment")
.ifNotExists()
@@ -277,6 +288,14 @@ const initDatabase = (filename: string) => Effect.gen(function* () {
.columns(["pieceId", "filename"])
.$call(execute);
yield* db.schema
.createTable("UserRole")
.ifNotExists()
.addColumn("userId", "text", (c) => c.notNull().references("User.userId").onDelete("cascade").onUpdate("cascade"))
.addColumn("role", "text", (c) => c.notNull())
.addPrimaryKeyConstraint("pk_UserRole", ["userId", "role"])
.$call(execute);
yield* db.schema
.createTable("Option")
.ifNotExists()