Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ on:
push:
branches:
- main
- scf_master
pull_request:
branches:
- main
- scf_master

env:
NODE_VERSION: 18
Expand Down Expand Up @@ -82,6 +84,10 @@ jobs:
fail-fast: false
matrix:
scenario:
- ember-lts-6.4
- ember-lts-5.12
- ember-lts-5.8
- ember-lts-5.4
- ember-lts-4.12
- ember-lts-4.8
- ember-lts-4.4
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
/npm-debug.log*
/testem.log
/yarn-error.log
/.nvmrc
/.tool-versions

# ember-try
/.node_modules.ember-try/
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ OpenID Connect [Authorization Code Flow](https://openid.net/specs/openid-connect

## Installation

- Ember.js v4.4 or above
- Ember CLI v4.4 or above
- Node.js v16 or above
- Ember.js v4.12 or above
- Ember CLI v4.12 or above
- Node.js v18 or above
- Ember Simple Auth v6 or above

Note: The addon uses [Proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy)
in its implementation, if IE browser support is necessary, a polyfill needs to be provided.
Expand Down Expand Up @@ -116,7 +117,6 @@ shows an example of a custom fetch service with proper authentication handling:
```js
import Service, { inject as service } from "@ember/service";
import { handleUnauthorized } from "ember-simple-auth-oidc";
import fetch from "fetch";

export default class FetchService extends Service {
@service session;
Expand Down
167 changes: 120 additions & 47 deletions addon/authenticators/oidc.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,58 @@
import { later } from "@ember/runloop";
import { inject as service } from "@ember/service";
import {
isServerErrorResponse,
isAbortError,
isBadRequestResponse,
} from "ember-fetch/errors";
import { lastValue, task } from "ember-concurrency";
import BaseAuthenticator from "ember-simple-auth/authenticators/base";
import fetch from "fetch";
import { resolve } from "rsvp";
import { TrackedObject } from "tracked-built-ins";

import config from "ember-simple-auth-oidc/config";
import getAbsoluteUrl from "ember-simple-auth-oidc/utils/absolute-url";
import {
isServerErrorResponse,
isAbortError,
isBadRequestResponse,
} from "ember-simple-auth-oidc/utils/errors";

const camelize = (s) => s.replace(/_./g, (x) => x[1].toUpperCase());

const camelizeObjectKeys = (obj) =>
Object.entries(obj).reduce((newObj, [key, value]) => {
return (newObj[camelize(key)] = value);
}, {});

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
Expand All @@ -28,37 +62,33 @@ export default class OidcAuthenticator extends BaseAuthenticator {
* @param {String} options.code The authentication code
* @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)",
);
async authenticate(options) {
if (!this.hasEndpointsConfigured) {
await this._fetchAuthConfiguration.perform();

if (!this.hasEndpointsConfigured) {
throw new Error(
"Please define all OIDC endpoints (auth, token, userinfo)",
);
}
}

const { isRefresh = false, redirectUri, customParams = {} } = options;

if (isRefresh) {
const DEFAULT_RETRY_COUNT = 0;
return await this._refresh(
this.session.data.authenticated.refresh_token,
redirectUri,
DEFAULT_RETRY_COUNT,
customParams,
);
}

const bodyObject = {
code,
client_id: this.config.clientId,
grant_type: "authorization_code",
redirect_uri: redirectUri,
};

if (this.config.enablePkce) {
bodyObject.code_verifier = codeVerifier;
}

const body = Object.keys(bodyObject)
.map((k) => `${k}=${encodeURIComponent(bodyObject[k])}`)
.join("&");
const body = this._buildBodyQuery(options);

const response = await fetch(
getAbsoluteUrl(this.config.tokenEndpoint, this.config.host),
getAbsoluteUrl(this.configuration.tokenEndpoint, this.config.host),
{
method: "POST",
headers: {
Expand Down Expand Up @@ -102,16 +132,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,
)}`,
);
}
Expand All @@ -122,8 +152,8 @@ export default class OidcAuthenticator extends BaseAuthenticator {

this._redirectToUrl(
`${getAbsoluteUrl(
this.config.endSessionEndpoint,
this.config.host,
this.configuration.endSessionEndpoint,
this.configuration.host,
)}?${params.join("&")}`,
);
}
Expand Down Expand Up @@ -163,21 +193,23 @@ export default class OidcAuthenticator extends BaseAuthenticator {
* @param {String} refresh_token The refresh token
* @returns {Object} The parsed response data
*/
async _refresh(refresh_token, redirectUri, retryCount = 0) {
async _refresh(
refresh_token,
redirectUri,
retryCount = 0,
customParams = {},
) {
let isServerError = false;
try {
const bodyObject = {
const body = this._buildBodyQuery({
redirectUri,
refresh_token,
client_id: this.config.clientId,
grant_type: "refresh_token",
redirect_uri: redirectUri,
};
const body = Object.keys(bodyObject)
.map((k) => `${k}=${encodeURIComponent(bodyObject[k])}`)
.join("&");
isRefresh: true,
customParams,
});

const response = await fetch(
getAbsoluteUrl(this.config.tokenEndpoint, this.config.host),
getAbsoluteUrl(this.configuration.tokenEndpoint, this.config.host),
{
method: "POST",
headers: {
Expand All @@ -203,7 +235,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(
Expand All @@ -212,7 +244,7 @@ export default class OidcAuthenticator extends BaseAuthenticator {
resolve(
this._refresh(refresh_token, redirectUri, retryCount + 1),
),
this.config.retryTimeout,
this.configuration.retryTimeout,
);
});
}
Expand All @@ -228,10 +260,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",
},
},
Expand Down Expand Up @@ -263,9 +295,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,
Expand All @@ -276,4 +310,43 @@ export default class OidcAuthenticator extends BaseAuthenticator {
redirectUri,
});
}

/**
* Builds query parameters string for the authorize or refresh request
*
* @param {*} options
* @returns string
*/
_buildBodyQuery({
code,
redirectUri,
codeVerifier,
isRefresh = false,
refresh_token,
customParams = {},
}) {
const bodyObject = {
redirect_uri: redirectUri,
client_id: this.configuration.clientId,
grant_type: isRefresh ? "refresh_token" : "authorization_code",
...customParams,
};

if (!isRefresh && code) {
bodyObject.code = code;
if (this.configuration.enablePkce) {
bodyObject.code_verifier = codeVerifier;
}
}

if (isRefresh && refresh_token) {
bodyObject.refresh_token = refresh_token;
}

const bodyQuery = Object.keys(bodyObject)
.map((k) => `${k}=${encodeURIComponent(bodyObject[k])}`)
.join("&");

return bodyQuery;
}
}
8 changes: 5 additions & 3 deletions addon/routes/oidc-authentication.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,9 @@ export default class OIDCAuthenticationRoute extends Route {
* @param {Object} transition.to.queryParams The query params of the transition
* @param {String} transition.to.queryParams.code The authentication code given by the identity provider
* @param {String} transition.to.queryParams.state The state given by the identity provider
* @param {Object} customParams Custom query params to be added to the redirect URL
*/
async afterModel(_, transition) {
async afterModel(_, transition, customParams = {}) {
if (!this.config.authEndpoint) {
throw new Error(
"Please define all OIDC endpoints (auth, token, logout, userinfo)",
Expand All @@ -84,7 +85,7 @@ export default class OIDCAuthenticationRoute extends Route {
);
}

return this._handleRedirectRequest(queryParams);
return this._handleRedirectRequest(queryParams, customParams);
}

/**
Expand Down Expand Up @@ -130,7 +131,7 @@ export default class OIDCAuthenticationRoute extends Route {
* match this state, otherwise the authentication will fail to prevent from
* CSRF attacks.
*/
_handleRedirectRequest(queryParams) {
_handleRedirectRequest(queryParams, customParams = {}) {
const state = v4();

// Store state to session data
Expand All @@ -155,6 +156,7 @@ export default class OIDCAuthenticationRoute extends Route {
`state=${state}`,
`scope=${this.config.scope}`,
queryParams[key] ? `${key}=${queryParams[key]}` : null,
new URLSearchParams(customParams).toString(),
];

if (this.config.enablePkce) {
Expand Down
Loading
Loading