diff --git a/addon/authenticators/oidc.js b/addon/authenticators/oidc.js index d41229ce..666779cf 100644 --- a/addon/authenticators/oidc.js +++ b/addon/authenticators/oidc.js @@ -1,5 +1,7 @@ import { later } from "@ember/runloop"; import { inject as service } from "@ember/service"; +import { camelize } from "@ember/string"; +import { lastValue, task } from "ember-concurrency"; import { isServerErrorResponse, isAbortError, @@ -13,12 +15,47 @@ import { TrackedObject } from "tracked-built-ins"; import config from "ember-simple-auth-oidc/config"; import getAbsoluteUrl from "ember-simple-auth-oidc/utils/absolute-url"; +const camelizeObjectKeys = (obj) => { + Object.keys(obj).forEach((key) => { + obj[camelize(key)] = obj[key]; + delete obj[key]; + }); + return obj; +}; + export default class OidcAuthenticator extends BaseAuthenticator { @service router; @service session; @config config; + get configuration() { + return { ...this.config, ...this.fetchedConfig }; + } + + get hasEndpointsConfigured() { + return ( + this.configuration.tokenEndpoint && this.configuration.userinfoEndpoint + ); + } + + /** + * Tries to fetch the OIDC provider configuration from the specified host/realm. + * SPEC: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig + */ + @lastValue("_fetchAuthConfiguration") fetchedConfig; + _fetchAuthConfiguration = task(async () => { + if (!this.config.host) { + throw new Error("Please define a OIDC host."); + } + const response = await fetch( + `${this.config.host}/.well-known/openid-configuration`, + ); + const _json = await response.json(); + + return camelizeObjectKeys(_json.data); + }); + /** * Authenticate the client with the given authentication code. The * authentication call will return an access and refresh token which will @@ -29,10 +66,14 @@ export default class OidcAuthenticator extends BaseAuthenticator { * @returns {Object} The parsed response data */ async authenticate({ code, redirectUri, codeVerifier, isRefresh }) { - if (!this.config.tokenEndpoint || !this.config.userinfoEndpoint) { - throw new Error( - "Please define all OIDC endpoints (auth, token, userinfo)", - ); + if (!this.hasEndpointsConfigured) { + await this._fetchAuthConfiguration.perform(); + + if (!this.hasEndpointsConfigured) { + throw new Error( + "Please define all OIDC endpoints (auth, token, userinfo)", + ); + } } if (isRefresh) { @@ -44,12 +85,12 @@ export default class OidcAuthenticator extends BaseAuthenticator { const bodyObject = { code, - client_id: this.config.clientId, + client_id: this.configuration.clientId, grant_type: "authorization_code", redirect_uri: redirectUri, }; - if (this.config.enablePkce) { + if (this.configuration.enablePkce) { bodyObject.code_verifier = codeVerifier; } @@ -58,7 +99,7 @@ export default class OidcAuthenticator extends BaseAuthenticator { .join("&"); const response = await fetch( - getAbsoluteUrl(this.config.tokenEndpoint, this.config.host), + getAbsoluteUrl(this.configuration.tokenEndpoint, this.config.host), { method: "POST", headers: { @@ -102,16 +143,16 @@ export default class OidcAuthenticator extends BaseAuthenticator { * @param {String} idToken The id_token of the session to invalidate */ singleLogout(idToken) { - if (!this.config.endSessionEndpoint) { + if (!this.configuration.endSessionEndpoint) { return; } const params = []; - if (this.config.afterLogoutUri) { + if (this.configuration.afterLogoutUri) { params.push( `post_logout_redirect_uri=${getAbsoluteUrl( - this.config.afterLogoutUri, + this.configuration.afterLogoutUri, )}`, ); } @@ -122,8 +163,8 @@ export default class OidcAuthenticator extends BaseAuthenticator { this._redirectToUrl( `${getAbsoluteUrl( - this.config.endSessionEndpoint, - this.config.host, + this.configuration.endSessionEndpoint, + this.configuration.host, )}?${params.join("&")}`, ); } @@ -168,7 +209,7 @@ export default class OidcAuthenticator extends BaseAuthenticator { try { const bodyObject = { refresh_token, - client_id: this.config.clientId, + client_id: this.configuration.clientId, grant_type: "refresh_token", redirect_uri: redirectUri, }; @@ -177,7 +218,7 @@ export default class OidcAuthenticator extends BaseAuthenticator { .join("&"); const response = await fetch( - getAbsoluteUrl(this.config.tokenEndpoint, this.config.host), + getAbsoluteUrl(this.configuration.tokenEndpoint, this.config.host), { method: "POST", headers: { @@ -203,7 +244,7 @@ export default class OidcAuthenticator extends BaseAuthenticator { } catch (e) { if ( (isServerError || isAbortError(e)) && - retryCount < this.config.amountOfRetries - 1 + retryCount < this.configuration.amountOfRetries - 1 ) { return new Promise((resolve) => { later( @@ -212,7 +253,7 @@ export default class OidcAuthenticator extends BaseAuthenticator { resolve( this._refresh(refresh_token, redirectUri, retryCount + 1), ), - this.config.retryTimeout, + this.configuration.retryTimeout, ); }); } @@ -228,10 +269,10 @@ export default class OidcAuthenticator extends BaseAuthenticator { */ async _getUserinfo(accessToken) { const response = await fetch( - getAbsoluteUrl(this.config.userinfoEndpoint, this.config.host), + getAbsoluteUrl(this.configuration.userinfoEndpoint, this.config.host), { headers: { - Authorization: `${this.config.authPrefix} ${accessToken}`, + Authorization: `${this.configuration.authPrefix} ${accessToken}`, Accept: "application/json", }, }, @@ -263,9 +304,11 @@ export default class OidcAuthenticator extends BaseAuthenticator { const expireInMilliseconds = expires_in ? expires_in * 1000 - : this.config.expiresIn; + : this.configuration.expiresIn; const expireTime = - new Date().getTime() + expireInMilliseconds - this.config.refreshLeeway; + new Date().getTime() + + expireInMilliseconds - + this.configuration.refreshLeeway; return new TrackedObject({ access_token, diff --git a/tests/dummy/mirage/config.js b/tests/dummy/mirage/config.js index 3d67a888..c2c86572 100644 --- a/tests/dummy/mirage/config.js +++ b/tests/dummy/mirage/config.js @@ -1,6 +1,9 @@ import { discoverEmberDataModels } from "ember-cli-mirage"; import { createServer, Response } from "miragejs"; +const REALM = "test-realm"; +const REALM_PATH = `/realms/${REALM}`; + export default function makeServer(config) { return createServer({ ...config, @@ -10,12 +13,30 @@ export default function makeServer(config) { this.namespace = ""; this.timing = 0; - this.post("/realms/test-realm/protocol/openid-connect/token", { + this.get(`${REALM_PATH}/.well-known/openid-configuration`, () => { + const config = { + issuer: this.urlPrefix, + authorization_endpoint: `${this.urlPrefix}/connect/authorize`, + token_endpoint: `${this.urlPrefix}${REALM_PATH}/protocol/openid-connect/token`, + userinfo_endpoint: `${this.urlPrefix}${REALM_PATH}/protocol/openid-connect/userinfo`, + end_session_endpoint: `${this.urlPrefix}/connect/end_session`, + jwks_uri: `${this.urlPrefix}/jwks.json`, + registration_endpoint: `${this.urlPrefix}/connect/register`, + }; + return new Response( + 200, + {}, + { + data: config, + }, + ); + }); + this.post(`${REALM_PATH}/protocol/openid-connect/token`, { access_token: "access.token", refresh_token: "refresh.token", id_token: "id.token", }); - this.get("/realms/test-realm/protocol/openid-connect/userinfo", { + this.get(`${REALM_PATH}/protocol/openid-connect/userinfo`, { sub: 1, }); this.get("/users");