Skip to content

Commit

Permalink
feat(apollo): add middleware for ember-apollo-client
Browse files Browse the repository at this point in the history
  • Loading branch information
anehx authored and luytena committed Mar 18, 2022
1 parent e39a009 commit 7e22f17
Show file tree
Hide file tree
Showing 15 changed files with 403 additions and 54 deletions.
70 changes: 44 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,21 @@ OpenID Connect [Authorization Code Flow](https://openid.net/specs/openid-connect

## Installation

* Ember.js v3.24 or above
* Ember CLI v3.24 or above
* Node.js v12 or above
- Ember.js v3.24 or above
- Ember CLI v3.24 or above
- Node.js v12 or above

Note: The addon uses [Proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy)
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.

```bash
$ ember install ember-simple-auth-oidc
```

If you're upgrading from 3.x to 4.x see the [upgrade guide](docs/migration-v4.md).

## Usage

To use the oidc authorization code flow the following elements need to be added
to the Ember application.

Expand All @@ -38,8 +40,8 @@ import OIDCAuthenticationRoute from "ember-simple-auth-oidc/routes/oidc-authenti
export default class LoginRoute extends OIDCAuthenticationRoute {}
```

Authenticated routes need to call `session.requireAuthentication` in their
respective `beforeModel`, to ensure that unauthenticated transitions are
Authenticated routes need to call `session.requireAuthentication` in their
respective `beforeModel`, to ensure that unauthenticated transitions are
prevented and redirected to the authentication route.

```js
Expand All @@ -60,9 +62,9 @@ export default class ProtectedRoute extends Route {
To include authorization info in all Ember Data requests override `headers` in
the application adapter and include `session.headers` alongside any other
necessary headers. By extending the application adapter from either of the
provided `OIDCJSONAPIAdapter` or `OIDCRESTAdapter`, the `access_token` is
refreshed before Ember Data requests, if necessary. Both the `OIDCJSONAPIAdapter`
and the `OIDCRESTAdapter` also provide default headers with the authorization
provided `OIDCJSONAPIAdapter` or `OIDCRESTAdapter`, the `access_token` is
refreshed before Ember Data requests, if necessary. Both the `OIDCJSONAPIAdapter`
and the `OIDCRESTAdapter` also provide default headers with the authorization
header included.

```js
Expand All @@ -80,44 +82,60 @@ export default class ApplicationAdapter extends OIDCJSONAPIAdapter {
}
```

Both the `OIDCJSONAPIAdapter` and `OIDCRESTAdapter` already handle unauthorized
requests and perform an invalidation of the session, which also remembers your
visited URL. If you want this behaviour for other request services as well, you
can use the `handleUnauthorized` function. The following snippet shows an
example `ember-apollo-client` afterware (error handling) implementation:
`ember-simple-auth-oidc` also provides a middleware which handles authorization
and unauthorization on the apollo service provided by `ember-apollo-client`.
Simply, wrap the http link in `apolloMiddleware` like so:

```js
// app/services/apollo.js

import { inject as service } from "@ember/service";
import { onError } from "apollo-link-error";
import ApolloService from "ember-apollo-client/services/apollo";
import { handleUnauthorized } from "ember-simple-auth-oidc";
import { apolloMiddleware } from "ember-simple-auth-oidc";

export default class CustomApolloService extends ApolloService {
@service session;

link() {
const httpLink = super.link();

const afterware = onError(error => {
const { networkError } = error;
return apolloMiddleware(httpLink, this.session);
}
}
```

if (networkError.statusCode === 401) {
handleUnauthorized(this.session);
}
});
The provided adapters and the apollo middleware already handle authorization and
unauthorized requests properly. If you want the same behaviour for other request
services as well, you can use the `handleUnauthorized` function and the
`refreshAuthentication.perform` method on the session. The following snippet
shows an example of a custom fetch service with proper authentication handling:

return afterware.concat(httpLink);
```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;

async fetch(url) {
await this.session.refreshAuthentication.perform();

const response = await fetch(url, { headers: this.session.headers });

if (!response.ok && response.status === 401) {
handleUnauthorized(this.session);
}

return response;
}
}
```

Ember Simple Auth encourages the manual setup of the session service in the `beforeModel` of the
application route, starting with [version 4.1.0](https://github.com/simplabs/ember-simple-auth/releases/tag/4.1.0).
Ember Simple Auth encourages the manual setup of the session service in the `beforeModel` of the
application route, starting with [version 4.1.0](https://github.com/simplabs/ember-simple-auth/releases/tag/4.1.0).
The relevant changes are described in their [upgrade to v4 guide](https://github.com/simplabs/ember-simple-auth/blob/master/guides/upgrade-to-v4.md).


### Logout / Explicit invalidation

There are two ways to invalidate (logout) the current session:
Expand Down
43 changes: 43 additions & 0 deletions addon/apollo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { assert } from "@ember/debug";
import {
dependencySatisfies,
macroCondition,
importSync,
} from "@embroider/macros";
import { handleUnauthorized } from "ember-simple-auth-oidc";

let apolloMiddleware;

if (macroCondition(dependencySatisfies("@apollo/client", ">=3.0.0"))) {
const { setContext } = importSync("@apollo/client/link/context");
const { onError } = importSync("@apollo/client/link/error");

apolloMiddleware = (httpLink, session) => {
const authMiddleware = setContext(async (_, context) => {
await session.refreshAuthentication.perform();

return {
...context,
headers: {
...context.headers,
...session.headers,
},
};
});

const authAfterware = onError((error) => {
if (error.networkError && error.networkError.statusCode === 401) {
handleUnauthorized(session);
}
});

return authMiddleware.concat(authAfterware).concat(httpLink);
};
} else {
apolloMiddleware = () =>
assert(
"@apollo/client >= 3.0.0 must be installed in order to use the apollo middleware"
);
}

export default apolloMiddleware;
23 changes: 2 additions & 21 deletions addon/index.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,2 @@
import { getOwner } from "@ember/application";

import { getConfig } from "ember-simple-auth-oidc/config";
import getAbsoluteUrl from "ember-simple-auth-oidc/utils/absoluteUrl";

export const handleUnauthorized = (session) => {
if (session.isAuthenticated) {
session.set("data.nextURL", location.href.replace(location.origin, ""));
session.invalidate();

const owner = getOwner(session);

if (
owner.resolveRegistration("config:environment").environment !== "test"
) {
location.replace(getAbsoluteUrl(getConfig(owner).afterLogoutUri || ""));
}
}
};

export default { handleUnauthorized };
export { default as handleUnauthorized } from "ember-simple-auth-oidc/unauthorized";
export { default as apolloMiddleware } from "ember-simple-auth-oidc/apollo";
19 changes: 19 additions & 0 deletions addon/unauthorized.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { getOwner } from "@ember/application";

import { getConfig } from "ember-simple-auth-oidc/config";
import getAbsoluteUrl from "ember-simple-auth-oidc/utils/absoluteUrl";

export default function handleUnauthorized(session) {
if (session.isAuthenticated) {
session.set("data.nextURL", location.href.replace(location.origin, ""));
session.invalidate();

const owner = getOwner(session);

if (
owner.resolveRegistration("config:environment").environment !== "test"
) {
location.replace(getAbsoluteUrl(getConfig(owner).afterLogoutUri || ""));
}
}
}
13 changes: 13 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"test:ember-compatibility": "ember try:each"
},
"dependencies": {
"@embroider/macros": "^1.5.0",
"ember-auto-import": "^2.4.0",
"ember-cli-babel": "^7.26.11",
"ember-concurrency": "^2.2.1",
Expand All @@ -37,12 +38,14 @@
"devDependencies": {
"@adfinis-sygroup/eslint-config": "1.5.0",
"@adfinis-sygroup/semantic-release-config": "3.4.0",
"@apollo/client": "3.5.10",
"@ember/optional-features": "2.0.0",
"@ember/test-helpers": "2.6.0",
"@embroider/test-setup": "1.5.0",
"@glimmer/tracking": "1.0.4",
"babel-eslint": "10.1.0",
"broccoli-asset-rev": "3.0.0",
"ember-apollo-client": "4.0.2",
"ember-cli": "4.2.0",
"ember-cli-dependency-checker": "3.2.0",
"ember-cli-htmlbars": "6.0.1",
Expand All @@ -67,13 +70,23 @@
"eslint-plugin-node": "11.1.0",
"eslint-plugin-prettier": "4.0.0",
"eslint-plugin-qunit": "7.2.0",
"graphql": "16.3.0",
"graphql-tag": "2.12.6",
"loader.js": "4.7.0",
"npm-run-all": "4.1.5",
"prettier": "2.6.0",
"qunit": "2.18.0",
"qunit-dom": "2.0.0",
"webpack": "5.70.0"
},
"peerDependencies": {
"@apollo/client": ">= 3.0.0"
},
"peerDependenciesMeta": {
"@apollo/client": {
"optional": true
}
},
"engines": {
"node": "12.* || 14.* || >= 16"
},
Expand Down
23 changes: 23 additions & 0 deletions tests/dummy/app/controllers/protected/apollo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Controller from "@ember/controller";
import { action } from "@ember/object";
import { queryManager } from "ember-apollo-client";
import { gql } from "graphql-tag";

export default class ApolloController extends Controller {
@queryManager apollo;

@action
triggerUnauthenticated(e) {
this.apollo.mutate({
mutation: gql`
mutation {
mutate {
clientMutationId
}
}
`,
});

e.preventDefault();
}
}
1 change: 1 addition & 0 deletions tests/dummy/app/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Router.map(function () {
this.route("users");
this.route("profile");
this.route("secret");
this.route("apollo");
});
this.route("oidc", {
path: "realms/test-realm/protocol/openid-connect/auth",
Expand Down
20 changes: 20 additions & 0 deletions tests/dummy/app/routes/protected/apollo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Route from "@ember/routing/route";
import { queryManager } from "ember-apollo-client";
import { gql } from "graphql-tag";

export default class ApolloRoute extends Route {
@queryManager apollo;

model() {
return this.apollo.watchQuery({
query: gql`
query {
items {
id
name
}
}
`,
});
}
}
13 changes: 13 additions & 0 deletions tests/dummy/app/services/apollo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { inject as service } from "@ember/service";
import ApolloService from "ember-apollo-client/services/apollo";
import { apolloMiddleware } from "ember-simple-auth-oidc";

export default class CustomApolloService extends ApolloService {
@service session;

link() {
const httpLink = super.link();

return apolloMiddleware(httpLink, this.session);
}
}
1 change: 1 addition & 0 deletions tests/dummy/app/templates/application.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
{{#if this.session.isAuthenticated}}
<LinkTo @route="protected.profile">Profile (401 handling demo)</LinkTo>
<LinkTo @route="protected.users">Users</LinkTo>
<LinkTo @route="protected.apollo">Apollo</LinkTo>
<a href="#" {{on "click" this.logout}}>Logout</a>
{{else}}
<LinkTo @route="login">Login</LinkTo>
Expand Down
7 changes: 7 additions & 0 deletions tests/dummy/app/templates/protected/apollo.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<ul>
{{#each @model.items as |item|}}
<li>{{item.name}}</li>
{{/each}}
</ul>

<button type="button" {{on "click" this.triggerUnauthenticated}}>Trigger 401</button>
4 changes: 3 additions & 1 deletion tests/dummy/config/environment.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ module.exports = function (environment) {
environment,
rootURL: "/",
locationType: "history",

apollo: {
apiURL: "http://localhost:4200/graphql",
},
"ember-simple-auth-oidc": {
host: "http://localhost:4200/realms/test-realm",
clientId: "test-client",
Expand Down
18 changes: 17 additions & 1 deletion tests/dummy/mirage/config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { discoverEmberDataModels } from "ember-cli-mirage";
import { createServer } from "miragejs";
import { createServer, Response } from "miragejs";

export default function makeServer(config) {
return createServer({
Expand All @@ -20,6 +20,22 @@ export default function makeServer(config) {
});
this.get("/users");
this.get("/users/1", {}, 401);

this.post("/graphql", (_, request) => {
const { query } = JSON.parse(request.requestBody);

if (query.startsWith("mutation")) {
return new Response(401);
}

return new Response(
200,
{},
{
data: { items: [{ id: 1, name: "Test" }] },
}
);
});
},
});
}
Loading

0 comments on commit 7e22f17

Please sign in to comment.