Friday, August 5, 2022

AppAuth-JS for Authorization Code Grant OpenID/OAuth

Implementing authorization code grant OpenID/OAuth with AppAuth-JS 1.3.1 may require these workarounds:

  • The public API for RedirectRequestHandler does not expose an auth flow completion method that gives negative feedback; if the callback does not contain proper authorization results (token, code etc.) the current completeAuthorizationRequestIfPossible() method fails silently with a console log, not giving any indication to the caller. To overcome this, you can subclass RedirectRequestHandler to change (expose) the already existing internal completeAuthorizationRequest() method from protected to public:
    import {RedirectRequestHandler} from '@openid/appauth';
    
    // ..
    
    class CallableRedirectRequestHandler extends RedirectRequestHandler {
    	completeAuthorizationRequest() {
    		return super.completeAuthorizationRequest();
    	}
    }
    Afterwards you can modify the auth completion path to use a CallableRedirectRequestHandler instance, and directly call completeAuthorizationRequest(), with a null-check on the result included in the returned Promise to handle missing/failed auth cases.
  • Callback URLs from code grants usually contain result/return data (code, state etc.) as query params, unlike implicit grant which typically uses a hash (for token, state etc.). However the default BasicQueryStringUtils always parses the callback URL assuming a hash format. When code grant is in use, one possible workaround is to extend parse() method of BasicQueryStringUtils, to always override/turn off useHash when it is invoked internally:
    import {BasicQueryStringUtils} from '@openid/appauth';
    
    // ..
    
    class NoHashQueryStringUtils extends BasicQueryStringUtils {
    	parse(input, useHash?) {
    		return super.parse(input, false);
    	}
    }

With these changes, an authorization code grant flow with AppAuth-JS in Angular may look like this:

import {
	AuthorizationRequest,
	AuthorizationServiceConfiguration,
	RedirectRequestHandler,
	BasicQueryStringUtils
} from '@openid/appauth';

// your OpenID provider configs
const config = {
	baseUrl: "your OpenID provider's base URL",
	clientId: "client ID",
	scopes: "space separated scopes list",
	tokenName: "key of the token field returned in the back-end code-exchange response; in case it is customized/changeable"
};

// ... component code

	/* call this method to start the auth process
	AppAuth will store request details into localStorage, and recover it from there during the next/completion phase */

	async initiateAuth() {
		let serverConfig;
		try {
			serverConfig = await AuthorizationServiceConfiguration.fetchFromIssuer(config.baseUrl);
		} catch (e) {
			throw new Error(`Error loading auth server details: ${e.message}`);
		}

		const handler = new RedirectRequestHandler();
		const request = new AuthorizationRequest({
			client_id: config.clientId,
			redirect_uri: `${window.location.origin}/openid-callback`,	// your callback URL
			scope: config.scopes || 'openid',
			response_type: AuthorizationRequest.RESPONSE_TYPE_CODE
		});
		handler.performAuthorizationRequest(serverConfig, request);
	}

	/* call this method when callback URL/page has loaded
	if previously saved details could be loaded from localStorage, and return parameters could be extracted successfully
	from the (callback) URL, completion call will receive an auth result;
	otherwise it will receive an empty/undefined value, in which case you can notify the user that auth was unsuccessful */

	async completeAuth() {
		const authResult = await new CallableRedirectRequestHandler(undefined, new NoHashQueryStringUtils())
			.completeAuthorizationRequest();
		if (!authResult) {
			throw new Error('Could not find an authentication attempt to proceed; did you open this page directly, without going through log-in?');
		}

		let contentHeader: Headers = new Headers();
		contentHeader.append('Content-Type', 'application/json');
		return this.http.post(/* your code-exchange backend URL */, JSON.stringify({
			code: authResult.response.code,
			verifier: authResult.request.internal['code_verifier']
		}), {
			headers: contentHeader
		}).toPromise()
			.then(response => response.json()[config.tokenName || 'id_token'])
			.catch(e => /* handle it */);
	}

// ..