Use Azure auth for no good reason

This commit is contained in:
2025-03-26 19:42:26 +01:00
parent 52933e617a
commit cec7d47c9e
17 changed files with 635 additions and 615 deletions

View File

@@ -0,0 +1,234 @@
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<OAuthConfiguration>({
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<OAuthConfiguration>({
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<string>;
readonly idToken: Option.Option<string>;
readonly refreshToken: Option.Option<string>;
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<string>;
readonly userId: Option.Option<UserId>;
readonly roles: readonly string[];
}
}
export const revalidateTokens = Effect.fn("revaildateTokens")(
function* ({ accessToken, idToken, refreshToken, external }: revaildateTokens.Args) {
const accessTokenPayload = Option.map(accessToken, getJwtTokenPayload<Model.AccessTokenPayload>);
const idTokenPayload = Option.map(accessToken, getJwtTokenPayload<Model.IdTokenPayload>);
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<Model.IdTokenPayload>(it),
}));
const res: revaildateTokens.Result = Object.freeze<revaildateTokens.Result>({
accessToken: Option.map(accessToken, (at) => Object.freeze({
token: at,
payload: getJwtTokenPayload<Model.AccessTokenPayload>(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<O extends object = object>(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);
},
);