import { UserId } from "common"; import { DateTime, Duration, Effect, Option, pipe } from "effect"; import { constant } from "effect/Function"; import * as Model from "./model"; import { Db } from "./services/db"; import { Session } from "./services/session"; export const TENANT_ID = "0817c403-92e4-4648-a9aa-f688ffc5f97a"; export const TENANT_SUBDOMAIN = "chkvoxastra"; export const CLIENT_ID = "e5948f7d-187b-44f9-80cd-63ffda86f9be"; export const OAUTH_SCOPE = "email offline_access openid profile https://graph.microsoft.com/User.Read.All"; export const REDIRECT_URI = process.env.NODE_ENV === "production" ? "https://music.renati.me/api/v1/login" : "http://localhost:3000/api/v1/login"; export const EXPIRATION_BUFFER = Duration.seconds(10); export interface OAuthConfiguration { readonly authorizationEndpoint: string; readonly tokenEndpoint: string; } export const INTERNAL_OAUTH_CONFIGURATION: OAuthConfiguration = Object.freeze({ authorizationEndpoint: `https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/authorize`, tokenEndpoint: `https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token`, }); export const EXTERNAL_OAUTH_CONFIGURATION: OAuthConfiguration = Object.freeze({ authorizationEndpoint: `https://${TENANT_SUBDOMAIN}.ciamlogin.com/${TENANT_ID}/oauth2/v2.0/authorize`, tokenEndpoint: `https://${TENANT_SUBDOMAIN}.ciamlogin.com/${TENANT_ID}/oauth2/v2.0/token`, }); export namespace makeAuthorizationUrl { export interface Args { readonly external: boolean; } } export const makeAuthorizationUrl = Effect.fn("makeAuthorizationUrl")( function* ({ external }: makeAuthorizationUrl.Args) { const { db, execute } = yield* Db; const sessionId = yield* Session; const { codeVerifier, codeChallenge } = generateCodeVerifier(); const state = generateRandomState(); yield* db .updateTable("Session") .set({ codeVerifier, state, external: external ? 1 : 0, accessToken: null, idToken: null, refreshToken: null, }) .where("sessionId", "=", sessionId) .$call(execute); const { authorizationEndpoint } = external ? EXTERNAL_OAUTH_CONFIGURATION : INTERNAL_OAUTH_CONFIGURATION; const url = new URL(authorizationEndpoint); url.searchParams.set("client_id", CLIENT_ID); url.searchParams.set("response_type", "code"); url.searchParams.set("redirect_uri", REDIRECT_URI); url.searchParams.set("scope", OAUTH_SCOPE); url.searchParams.set("response_mode", "form_post"); url.searchParams.set("state", state); url.searchParams.set("prompt", "select_account"); url.searchParams.set("code_challenge", codeChallenge); url.searchParams.set("code_challenge_method", "S256"); return url.toString(); }, ); export namespace revaildateTokens { export interface Args { readonly accessToken: Option.Option; readonly idToken: Option.Option; readonly refreshToken: Option.Option; readonly external: boolean; } export interface Result { readonly accessToken: Option.Option<{ readonly token: string, readonly payload: Model.AccessTokenPayload, }>; readonly idToken: Option.Option<{ readonly token: string, readonly payload: Model.IdTokenPayload, }>; readonly refreshToken: Option.Option; readonly userId: Option.Option; readonly roles: readonly string[]; } } export const revalidateTokens = Effect.fn("revaildateTokens")( function* ({ accessToken, idToken, refreshToken, external }: revaildateTokens.Args) { const accessTokenPayload = Option.map(accessToken, getJwtTokenPayload); const idTokenPayload = Option.map(accessToken, getJwtTokenPayload); const expirationThreshold = yield* pipe( DateTime.now, Effect.map(DateTime.addDuration(EXPIRATION_BUFFER)), ); // Token expired or missing if (Option.match(accessTokenPayload, { onNone: constant(false), onSome: (atp) => DateTime.greaterThan(expirationThreshold, DateTime.unsafeMake(1000 * atp.exp)), }) || Option.match(idTokenPayload, { onNone: constant(false), onSome: (itp) => DateTime.greaterThan(expirationThreshold, DateTime.unsafeMake(1000 * itp.exp)), })) { accessToken = Option.none(); idToken = Option.none(); // try refreshing if (Option.isSome(refreshToken)) { const refreshTokenValue = refreshToken.value; const { tokenEndpoint } = external ? EXTERNAL_OAUTH_CONFIGURATION : INTERNAL_OAUTH_CONFIGURATION; const res = yield* Effect.promise((signal) => fetch(tokenEndpoint, { method: "POST", signal, headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ "client_id": CLIENT_ID, "grant_type": "refresh_token", "refresh_token": refreshTokenValue, "client_secret": process.env.CLIENT_SECRET!, }).toString(), })); const json = (yield* Effect.promise(() => res.json())) as { access_token: string, refresh_token: string, id_token: string }; accessToken = Option.some(json.access_token); idToken = Option.some(json.id_token); refreshToken = Option.some(json.refresh_token); } } const it = Option.map(idToken, (it) => Object.freeze({ token: it, payload: getJwtTokenPayload(it), })); const res: revaildateTokens.Result = Object.freeze({ accessToken: Option.map(accessToken, (at) => Object.freeze({ token: at, payload: getJwtTokenPayload(at), })), idToken: it, refreshToken, userId: Option.map(it, ({ payload: { oid } }) => oid), roles: Option.match(it, { onNone: constant(Object.freeze([])), onSome: ({ payload: { roles } }) => roles, }), }); return res; }, ); function getJwtTokenPayload(token: string): O { return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf-8")) as O; } export namespace generateCodeVerifier { export interface Result { codeVerifier: string; codeChallenge: string; } } export function generateCodeVerifier(byteLength: number = 32): generateCodeVerifier.Result { const codeVerifierBytes = new Uint8Array(byteLength); crypto.getRandomValues(codeVerifierBytes); const codeVerifier = Buffer.from(codeVerifierBytes).toString("base64url"); const codeVerifierAsciiBuffer = Buffer.from(codeVerifier, "ascii"); const codeVerifierAsciiArray = new Uint8Array( codeVerifierAsciiBuffer.buffer, codeVerifierAsciiBuffer.byteOffset, codeVerifierAsciiBuffer.length, ); const codeChallenge = Bun.SHA256.hash(codeVerifierAsciiArray, "base64url"); return { codeVerifier, codeChallenge }; } export function generateRandomState(byteLength: number = 32): string { const array = new Uint8Array(byteLength); crypto.getRandomValues(array); const state = Buffer.from(array).toString("base64url"); return state; } export namespace getUser { export interface Args { readonly accessToken: string; readonly userId: UserId; } export interface Result { readonly displayName: string; } } export const getUser = Effect.fn("getUser")( function* ({ accessToken, userId }: getUser.Args) { const res = yield* Effect.promise((signal) => fetch(`https://graph.microsoft.com/v1.0/users/${userId}?$select=displayName`, { signal, headers: { "Authorization": `Bearer ${accessToken}`, }, })); if (res.status === 404) { return Option.none(); } const json = (yield* Effect.promise(() => res.json())) as getUser.Result; return Option.some(json); }, );