#!/usr/bin/env -S deno run --allow-net --allow-read --allow-run
// ^- This is some Unix-magic to allow running this script as a stand alone
// program. On Windows and other non-Unix OSes, run this script as:
//
//    deno run --allow-net --allow-read --allow-run lookup.ts
//
// Deno is a tool that lets you run JavaScript locally. Installation
// instructions for Linux, macOS and Windows are at
// https://deno.land/manual@v1.15.3/getting_started/installation.
//
// This script demonstrates calling the Lookup API published at the API Gateway
// and authenticating as a normal user rather than as an Application. In order
// to demonstrate this, the script will print out the authenticated user"s
// profile on Lookup including any private fields.
//
// You must have already generated a client id (or "key") for an App on
// https://developer.api.apps.cam.ac.uk/ and enabled the "Lookup" API for your
// App. The "callback URL" for the App must be
// "http://localhost:4000/oauth2/callback". See the documentation linked to on
// https://developer.api.apps.cam.ac.uk/ for more information on this.

//// DEPENDENCIES ////

// "open" allows opening URLs in the user"s default browser.
import { open } from "https://deno.land/x/open/index.ts"

// "Server" is used to implement a small HTTP server which listens for the
// authentication response from the API Gateway.
import { Server } from "https://deno.land/std/http/server.ts"

// Import an implementation of the authentication protocol used by the API
// Gateway from the Microsoft Authentication Library (MSAL).
import { PublicClientApplication, ProtocolMode } from "https://deno.land/x/azure_msal_deno@v1.1.0/mod.ts"

// The API Gateway makes use of Proof Key for Code Exchange (PKCE) to increase
// security. Using PKCE is best practice for all clients.
//
// See: https://datatracker.ietf.org/doc/html/rfc7636)
import { create as createPKCEVerifierPair } from "https://deno.land/x/pkce_deno/mod.ts"

// The API Gateway will pass us back a random "state" token we give it. We use
// this to verify that the request we got back from the gateway matches the
// authorisation we triggered.
import { cryptoRandomString } from "https://deno.land/x/crypto_random_string@1.0.0/mod.ts"

//// CONSTANTS ////

// The client id registered for the App on the API Gateway developer portal.
// This is not a secret.
const clientId = "s7w86kczmWEUncn7oerwAt23BC4zaE9u"

// Create a new client application using the Microsoft Sign-in library.
console.log("Creating the client object to perform a sign in.")
const client = new PublicClientApplication({
  auth: {
    authorityMetadata: JSON.stringify({
      // These endpoints can be found at https://api.apps.cam.ac.uk/oauth2/v1/.well-known/openid-configuration
      // or at https://developer.api.apps.cam.ac.uk/docs/oauth2/1/overview.
      authorization_endpoint: "https://api.apps.cam.ac.uk/oauth2/v1/auth",
      token_endpoint: "https://api.apps.cam.ac.uk/oauth2/v1/token",
    }),
    clientId,
    knownAuthorities: ["api.apps.cam.ac.uk"],
    protocolMode: ProtocolMode.OIDC,
  }
})

//// LOGIC ////

// Some common parameters we need to pass to the API Gateway as part of the
// authentication dance.
//
// Passing "https://api.apps.cam.ac.uk/lookup" as the scope means we"ll ask the
// user for permission to use Lookup on their behalf.
//
// The App registered at https://developer.api.apps.cam.ac.uk/ _must_ have the
// callback URL set to "http://localhost:4000/oauth2/callback".
const authParams = {
  scopes: ["https://api.apps.cam.ac.uk/lookup"],
  redirectUri: "http://localhost:4000/oauth2/callback",
}

// Create some random tokens required for PKCE and security best practice.
const state = cryptoRandomString({ length: 64, type: "alphanumeric" })
const nonce = cryptoRandomString({ length: 64, type: "alphanumeric" })
const { codeVerifier, codeChallenge } = createPKCEVerifierPair()

// A handler function to handle requests received back from the API Gateway.
const handleRequests = async (request: Request) => {
  // Pull out the path (the part before "?") from the request URL.
  const url = new URL(request.url)
  const path = url.pathname

  if(path === "/oauth2/callback") {
    // Handle the response. (See below.)
    handleCodeResponse(url)

    // Thank the user.
    return new Response(
      "Thanks, you can now close the browser tab and look for the result on the console."
    )
  } else {
    // Otherwise, return "not found"
    return new Response("Not found", { status: 404 })
  }
}

// Make a new web server to listen to requests from the Gateway.
const server = new Server({
  addr: "0.0.0.0:4000",
  handler: handleRequests
})

// This is a "Promise" which we can "await". The "await" will continue once the
// server stops.
const serverPromise = server.listenAndServe()

// We start the authentication by getting the user to visit a URL generated by
// the MSAL client.
const authUrl = await client.getAuthCodeUrl({
  ...authParams,
  codeChallenge,
  codeChallengeMethod: "S256",
  state,
  nonce,
})

// Tell the user to visit the URL.
console.log("Visit the following URL in your browser. I'll also try to open it")
console.log("for you in your default browser.\n")
console.log(`     ${authUrl}\n`)

// Also try opening the URL in the user"s preferred browser.
await open(authUrl, {
  wait: false, url: Deno.build.os === "windows"
})

// A handler function called when we"ve got a response back from the API
// gateway with a code.
const handleCodeResponse = async (url: URL) => {
  // We should have been redirected back to a URL of the form
  //
  //    http://localhost:4000/oauth2/callback?code={...}&state={...}
  //
  // Pull the code and state out of the URL.
  const code = url.searchParams.get("code")
  const receivedState = url.searchParams.get("state")

  // We should"ve got a code.
  if(!code) {
    throw new Error("No code returned from API Gateway.")
  }

  // The state should match the one we sent with the original authentication
  // request.
  if(receivedState !== state) {
    throw new Error("Incorrect state received from API Gateway.")
  }

  // Get an access token in exchange for the code.
  console.log("Exchanging the auth code from the Gateway for a token...")
  const authenticationResult = await client.acquireTokenByCode({
    ...authParams,
    code,
    codeVerifier,
  })

  // Check we got a response.
  if(!authenticationResult) {
    throw new Error("No response from API Gateway.")
  }

  // Extract claims about the user returned by the Gateway. The Microsoft
  // Authentication Library will already have verified these using the public
  // key associated with the API Gateway.
  const { idTokenClaims, accessToken } = authenticationResult
  console.log("Successfully got token from Gateway. ID token claims:\n")
  console.log(JSON.stringify(idTokenClaims, null, 2))
  console.log("\n")

  // The id token should contain the CRSid of the user.
  const [crsid, namespace] = (idTokenClaims as { sub: string; }).sub.split("@")

  // The API Gateway can authenticate a great number of identities. Make sure
  // this is a CRSid based one.
  if(namespace !== "v1.person.identifiers.cam.ac.uk") {
    throw new Error("Some user without a CRSid authenticated.")
  }

  // Fetch the profile of the authenticated user. Note that this can include
  // "private" attributes because we've authenticated as the user. To
  // demonstrate this we'll fetch the "@cam forwarding email", the tombstone
  // email address (for when the user leaves) and any emergency contact
  // information the user may have added.
  const fetchAttributes = [
    "all_identifiers",
    "departingEmail",
    "emergencyContact",
    "forwardingEmail",
  ]
  console.log(`Making a request to Lookup to get profile of ${crsid}...`)
  const personResponse = await fetch(
    `https://api.apps.cam.ac.uk/lookup/v1/person/crsid/${crsid}?fetch=${fetchAttributes.join(",")}`,
    {
      mode: "cors",
      credentials: "omit",
      headers: {
        Authorization: `Bearer ${accessToken}`,
        Accept: "application/json"
      },
    }
  )

  // Check response from Lookup.
  if(!personResponse.ok) {
    console.error("Error status:", personResponse.status)
    console.error("Error body:", await personResponse.text())
    throw new Error("Error received from Lookup")
  }

  // Log the received profile.
  console.log("Received user profile from Lookup:\n")
  console.log(JSON.stringify(await personResponse.json(), null, 2))

  // We want to stop the server now we"ve got a token.
  server.close()
}

// Wait for server to exit.
await serverPromise

// vim:sw=2:ts=2:et:sts