{"version":3,"file":"entra-CJSyeQsx.mjs","names":[],"sources":["../src/lib/auth/entra.ts"],"sourcesContent":["import {\n  ConfidentialClientApplication,\n  CryptoProvider,\n  InteractionRequiredAuthError,\n  ServerError,\n} from \"@azure/msal-node\";\nimport type { TokenPayload } from \"~/models/TokenPayload\";\nimport { serverEnv } from \"~/env\";\n\n/**\n * Scopes requested during the interactive login. `offline_access` yields the\n * refresh token that the session is built around; `User.Read` lets us read the\n * user's Graph profile (e.g. avatar). Resource API scopes (file, approval, …) are\n * acquired on demand via {@link acquireDelegatedToken} using the multi-resource\n * refresh token.\n */\nexport const LOGIN_SCOPES = [\"openid\", \"profile\", \"offline_access\", \"User.Read\"];\n\n/** Entra authority for this tenant. */\nexport const ENTRA_AUTHORITY = `https://login.microsoftonline.com/${serverEnv.ENTRA_TENANT_ID}`;\n\n/** Shared across login/callback/refresh calls — stateless and cheap to reuse. */\nexport const cryptoProvider = new CryptoProvider();\n\n/**\n * Creates a confidential client (no persistent cache plugin).\n *\n * The interactive/refresh flows below deliberately use a FRESH client per token\n * operation rather than a singleton: the client's in-memory cache then holds only\n * that operation's tokens, so {@link extractRefreshToken} reads the correct user's\n * refresh token — a shared client would accumulate (and leak) every user's tokens.\n * Persisting just the refresh token (not the whole multi-KB MSAL cache) is what\n * keeps the session in a small cookie with no server-side token store.\n */\nexport const createConfidentialClient = (): ConfidentialClientApplication =>\n  new ConfidentialClientApplication({\n    auth: {\n      clientId: serverEnv.ENTRA_CLIENT_ID,\n      clientSecret: serverEnv.ENTRA_CLIENT_SECRET,\n      authority: ENTRA_AUTHORITY,\n    },\n  });\n\n/**\n * Pulls the refresh-token secret out of a client's in-memory MSAL cache. MSAL\n * intentionally hides refresh tokens, so reading the serialized cache is the only\n * way to persist just the RT (instead of the whole cache) in the session cookie.\n */\nfunction extractRefreshToken(client: ConfidentialClientApplication): string | undefined {\n  const cache = JSON.parse(client.getTokenCache().serialize()) as {\n    RefreshToken?: Record<string, { secret?: string }>;\n  };\n  return Object.values(cache.RefreshToken ?? {})[0]?.secret;\n}\n\n/** First leg of the auth-code flow: the URL to redirect the browser to. */\nexport function buildAuthCodeUrl(opts: {\n  redirectUri: string;\n  state: string;\n  codeChallenge: string;\n}): Promise<string> {\n  return createConfidentialClient().getAuthCodeUrl({\n    scopes: LOGIN_SCOPES,\n    redirectUri: opts.redirectUri,\n    responseMode: \"query\",\n    state: opts.state,\n    codeChallenge: opts.codeChallenge,\n    codeChallengeMethod: \"S256\",\n  });\n}\n\n/**\n * Second leg of the auth-code flow: exchange the code for tokens, returning the\n * refresh token and the id-token claims to persist in the session.\n */\nexport async function exchangeCodeForSession(opts: {\n  code: string;\n  redirectUri: string;\n  codeVerifier: string;\n}): Promise<{ refreshToken: string; claims: TokenPayload }> {\n  const client = createConfidentialClient();\n  const result = await client.acquireTokenByCode({\n    code: opts.code,\n    scopes: LOGIN_SCOPES,\n    redirectUri: opts.redirectUri,\n    codeVerifier: opts.codeVerifier,\n  });\n\n  const refreshToken = extractRefreshToken(client);\n  if (!refreshToken)\n    throw new Error(\"No refresh token returned (is the offline_access scope granted?)\");\n\n  return { refreshToken, claims: (result.idTokenClaims ?? {}) as TokenPayload };\n}\n\n/**\n * Acquires a delegated access token for `scopes` from the stored refresh token.\n * Entra rotates the refresh token on each use, so the (possibly new) refresh\n * token is returned for the caller to persist. Throws when the refresh token is\n * expired/revoked and interaction is required.\n */\n/** The refresh token is expired/revoked — the user must sign in interactively. */\nexport class SessionExpiredError extends Error {}\n\nexport async function acquireDelegatedToken(opts: {\n  refreshToken: string;\n  scopes: ReadonlyArray<string>;\n}): Promise<{ accessToken: string; refreshToken: string }> {\n  const client = createConfidentialClient();\n  let result;\n  try {\n    result = await client.acquireTokenByRefreshToken({\n      refreshToken: opts.refreshToken,\n      scopes: [...opts.scopes],\n      forceCache: true,\n    });\n  } catch (error) {\n    // Revocation, inactivity expiry (AADSTS70008/700082/50173) and Conditional\n    // Access sign-in frequency (AADSTS70043) all surface as interaction-required\n    // or invalid_grant — the session is dead, only an interactive login helps.\n    // Missing consent for the requested resource (AADSTS65001) looks the same to\n    // MSAL but is a configuration bug, not a dead session — let it surface as-is.\n    if (\n      (error instanceof InteractionRequiredAuthError && error.subError !== \"consent_required\") ||\n      (error instanceof ServerError && error.errorCode === \"invalid_grant\")\n    )\n      throw new SessionExpiredError(error.message);\n    throw error;\n  }\n  if (!result) throw new Error(\"Failed to acquire token from refresh token\");\n\n  return {\n    accessToken: result.accessToken,\n    refreshToken: extractRefreshToken(client) ?? opts.refreshToken,\n  };\n}\n\n/** Entra's front-channel logout endpoint, which clears the user's IdP session. */\nexport function buildLogoutUrl(postLogoutRedirectUri: string): string {\n  const base = `${ENTRA_AUTHORITY}/oauth2/v2.0/logout`;\n  return `${base}?post_logout_redirect_uri=${encodeURIComponent(postLogoutRedirectUri)}`;\n}\n"],"mappings":";;;;;;;;;;AAgBA,MAAa,eAAe;CAAC;CAAU;CAAW;CAAkB;AAAW;;AAG/E,MAAa,kBAAkB,qCAAqC,UAAU;;AAG9E,MAAa,iBAAiB,IAAI,eAAe;;;;;;;;;;;AAYjD,MAAa,iCACX,IAAI,8BAA8B,EAChC,MAAM;CACJ,UAAU,UAAU;CACpB,cAAc,UAAU;CACxB,WAAW;AACb,EACF,CAAC;;;;;;AAOH,SAAS,oBAAoB,QAA2D;CACtF,MAAM,QAAQ,KAAK,MAAM,OAAO,cAAc,CAAC,CAAC,UAAU,CAAC;CAG3D,OAAO,OAAO,OAAO,MAAM,gBAAgB,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE;AACrD;;AAGA,SAAgB,iBAAiB,MAIb;CAClB,OAAO,yBAAyB,CAAC,CAAC,eAAe;EAC/C,QAAQ;EACR,aAAa,KAAK;EAClB,cAAc;EACd,OAAO,KAAK;EACZ,eAAe,KAAK;EACpB,qBAAqB;CACvB,CAAC;AACH;;;;;AAMA,eAAsB,uBAAuB,MAIe;CAC1D,MAAM,SAAS,yBAAyB;CACxC,MAAM,SAAS,MAAM,OAAO,mBAAmB;EAC7C,MAAM,KAAK;EACX,QAAQ;EACR,aAAa,KAAK;EAClB,cAAc,KAAK;CACrB,CAAC;CAED,MAAM,eAAe,oBAAoB,MAAM;CAC/C,IAAI,CAAC,cACH,MAAM,IAAI,MAAM,kEAAkE;CAEpF,OAAO;EAAE;EAAc,QAAS,OAAO,iBAAiB,CAAC;CAAmB;AAC9E;;;;;;;;AASA,IAAa,sBAAb,cAAyC,MAAM,CAAA;AAE/C,eAAsB,sBAAsB,MAGe;CACzD,MAAM,SAAS,yBAAyB;CACxC,IAAI;CACJ,IAAI;EACF,SAAS,MAAM,OAAO,2BAA2B;GAC/C,cAAc,KAAK;GACnB,QAAQ,CAAC,GAAG,KAAK,MAAM;GACvB,YAAY;EACd,CAAC;CACH,SAAS,OAAO;EAMd,IACG,iBAAiB,gCAAgC,MAAM,aAAa,sBACpE,iBAAiB,eAAe,MAAM,cAAc,iBAErD,MAAM,IAAI,oBAAoB,MAAM,OAAO;EAC7C,MAAM;CACR;CACA,IAAI,CAAC,QAAQ,MAAM,IAAI,MAAM,4CAA4C;CAEzE,OAAO;EACL,aAAa,OAAO;EACpB,cAAc,oBAAoB,MAAM,KAAK,KAAK;CACpD;AACF;;AAGA,SAAgB,eAAe,uBAAuC;CAEpE,OAAO,GAAG,GADM,gBAAe,qBACjB,4BAA6B,mBAAmB,qBAAqB;AACrF"}