Use Azure auth for no good reason
This commit is contained in:
234
packages/backend/src/auth.ts
Normal file
234
packages/backend/src/auth.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user