Example of a JavaScript Single Page Application that uses OpenID Connect 1.0 for logon and then invokes an OAuth 2.0 protected API. Launch this app by navigating to
https://psteniusubi.github.io/SimpleSPA/spa.html
The code for the API invoked by this application is in SimpleAPI
See also my related blog article Ubisecure SSO and Single Page Applications.
This project is hosted on GitHub Pages here. It also runs as-is on Apache HTTP server or any other web server that serves static resources.
This application is implemented in a single html page spa.html.
This method fetches the OpenID Provider metadata configuration information. The issuer parameter is the name of the OpenID Provider.
asyncfunctiongetConfiguration(issuer){consturi=`${issuer}/.well-known/openid-configuration`;constresponse=awaitfetch(uri);if(!response.ok)throw{error: "http_error",response: response};returnawaitresponse.json();}This method builds an OpenID Connect authentication request and redirects the web browser to the OpenID Provider.
The code also creates a random nonce and PKCE code verifier. These items are stored in local storage with window.localStorage.setItem.
Generating PKCE code verifier
asyncfunctionnewCodeVerifier(method){switch(method){case"plain":
case"S256":
returnbtoaUrlSafe(Array.from(window.crypto.getRandomValues(newUint8Array(32)),t=>String.fromCharCode(t)).join(""))case"":
casenull:
returnnull;default:
throw"invalid argument";}}PKCE code challenge
asyncfunctiongetCodeChallenge(method,code_verifier){switch(method){case"plain":
if(code_verifier===null)throw"invalid argument";returncode_verifier;case"S256":
if(code_verifier===null)throw"invalid argument";letbytes=Uint8Array.from(code_verifier,t=>t.charCodeAt(0));bytes=awaitwindow.crypto.subtle.digest("SHA-256",bytes);returnbtoaUrlSafe(Array.from(newUint8Array(bytes),t=>String.fromCharCode(t)).join(""));case"":
casenull:
returnnull;default:
throw"invalid argument";}}Authentication request
asyncfunctionsendAuthenticationRequest(configuration,client_id,scope){constauthorization_request=newURL(configuration.authorization_endpoint);authorization_request.searchParams.set("response_type","code");authorization_request.searchParams.set("scope",scope);authorization_request.searchParams.set("client_id",client_id);authorization_request.searchParams.set("redirect_uri",location.origin+location.pathname);// nonceconstnonce=Array.from(window.crypto.getRandomValues(newUint32Array(4)),t=>t.toString(36)).join("");authorization_request.searchParams.set("nonce",nonce);window.localStorage.setItem("/SimpleSPA#nonce",nonce);// code_challenge_methodconstcode_challenge_method="S256";authorization_request.searchParams.set("code_challenge_method",code_challenge_method);// code_verifierconstcode_verifier=awaitnewCodeVerifier(code_challenge_method);window.localStorage.setItem("/SimpleSPA#code_verifier",code_verifier);// code_challengeconstcode_challenge=awaitgetCodeChallenge(code_challenge_method,code_verifier);authorization_request.searchParams.set("code_challenge",code_challenge);location.assign(authorization_request);}The OpenID Provider redirects user agent back with authorization response message, containing either code or error parameters.
This code looks for code or error url parameters, then uses window.history.replaceState to remove url parameters from history.
If a code parameter is present then a token request is issued.
asyncfunctionhandleAuthenticationResponse(){constparams=newURLSearchParams(location.search.substr(1));if(params.has("code")){window.history.replaceState(null,null,location.pathname);constconfig=awaitgetConfiguration(registration.issuer);consttokenResponse=awaitinvokeTokenRequest(config,registration.client_id,registration.client_secret,params.get("code"));if("id_token"intokenResponse){constjwks=awaitgetJWKS(config);constid_token=awaitdecodeJWT(jwks,tokenResponse.id_token);constsignature_status=(id_token.signature===true) ? "signature verified" : "invalid signature";document.getElementById("signature").innerText=`(${signature_status})`;set_value("id_token",JSON.stringify(id_token.claims,null,2));constnonce_status=(id_token.claims.nonce==localStorage.getItem("/SimpleSPA#nonce")) ? "nonce verified" : "invalid nonce";document.getElementById("nonce").innerText=`(${nonce_status})`;localStorage.removeItem("/SimpleSPA#nonce");}if("access_token"intokenResponse){fetchWithToken=(input,init)=>{varrequest=newRequest(input,init);request.headers.set("Authorization","Bearer "+tokenResponse.access_token);returnwindow.fetch(request);};}else{fetchWithToken=null;}return;}if(params.has("error")){set_value("id_token",`error=${params.get("error")}`);}}The following builds and invokes an OAuth authorization code grant token request.
asyncfunctioninvokeTokenRequest(configuration,client_id,client_secret,code){consttoken_endpoint=configuration.token_endpoint;constheaders={"Content-Type": "application/x-www-form-urlencoded"};constbody=newURLSearchParams();body.set("grant_type","authorization_code");body.set("code",code);body.set("client_id",client_id);body.set("client_secret",client_secret);body.set("redirect_uri",location.origin+location.pathname);constcode_verifier=window.localStorage.getItem("/SimpleSPA#code_verifier");if(code_verifier){body.set("code_verifier",code_verifier);}try{constresponse=awaitfetch(token_endpoint,{method: "POST",mode: "cors",headers: headers,body: body.toString()});if(!response.ok)throw{error: "http_error",response: response};returnawaitresponse.json();}finally{window.localStorage.removeItem("/SimpleSPA#code_verifier");}}The OpenID Provider's public keys are found in a JWKS document found from address specified by jwks_uri metadata property.
asyncfunctiongetJWKS(config){consturi=config.jwks_uri;constresponse=awaitfetch(uri);if(!response.ok)throw{error: "http_error",response: response};returnawaitresponse.json();}ID Token is formatted as JWT, with three base64url encoded segments separated by "." character. The first part contains header, second part contains claims and final part is the signature which covers the first and second part.
The WebCrypto API works with Uint8Array types so some type conversion with Uint8Array.from is needed.
asyncfunctiondecodeJWT(jwks,jwt){constjws=jwt.split(".");constheader=JSON.parse(atobUrlSafe(jws[0]));constclaims=JSON.parse(atobUrlSafe(jws[1]));consttext2verify=Uint8Array.from(jws[0]+"."+jws[1],t=>t.charCodeAt(0));constsignature=Uint8Array.from(atobUrlSafe(jws[2]),t=>t.charCodeAt(0));Each signing key from OpenID Provider's jwks document is converted into WebCrypto Key with window.crypto.subtle.importKey.
Then signature verification is attempted with window.crypto.subtle.verify.
Apparently there are some interoperability issues with JWK formatted keys and WebCrypto API which requires some transformation. One would assume algorithm and key identifiers of JWK, JWS and WebCrypto would be compatible but that appear to not be the case. In the example below I have hard coded RS256 algorithm. A real world solution needs to map JWK and JWS identifiers into WebCrypto identifiers.
constkeys=jwks.keys.filter(isSig).map(toJwk);constRS256={name: "RSASSA-PKCS1-v1_5",hash: {name: "SHA-256"},};for(constjwkofkeys){try{constkey=awaitwindow.crypto.subtle.importKey("jwk",jwk,RS256,false,["verify"]);constresult=awaitwindow.crypto.subtle.verify(RS256,key,signature,text2verify);if(result===true){return{"header": header,"claims": claims,"signature": true,"jwk": jwk,};}}catch{// ignore}}returnnegative;}When invoking an OAuth protected API the access token is put into the Authorization http request header with the Bearer scheme.
fetchWithToken=(input,init)=>{varrequest=newRequest(input,init);request.headers.set("Authorization","Bearer "+tokenResponse.access_token);returnwindow.fetch(request);};asyncfunctioninvokeApi(){const_fetch=fetchWithToken||window.fetch;constresponse=await_fetch(api_endpoint,{mode: "cors",cache: "no-store"});if(!response.ok)throw{error: "http_error",response: response};returnawaitresponse.json();}This application is ready to run with Ubisecure SSO at login.example.ubidemo.com.
- Navigate to https://psteniusubi.github.io/SimpleSPA/spa.html