#!/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