feat: oidc-client.js based Svelte OidcComponent

follows a pattern similar to @dopry/svelte-auth0,
but uses the more standards compliant oidc-client.js
library.
This commit is contained in:
Darrel O'Pry 2020-05-28 11:29:21 -04:00
commit 4fd62abe31
25 changed files with 7069 additions and 0 deletions

20
.eslintrc.js Normal file
View file

@ -0,0 +1,20 @@
module.exports = {
env: {
browser: true,
node: true,
es6: true,
'cypress/globals': true,
},
extends: ['eslint:recommended', 'plugin:cypress/recommended', 'prettier'],
overrides: [
{
files: '*.svelte',
processor: 'svelte3/svelte3',
},
],
parserOptions: {
ecmaVersion: 2019,
sourceType: 'module',
},
plugins: ['svelte3', 'cypress'],
};

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
.DS_Store
**/dist/**
**/node_modules/**
cypress/videos
cypress/screenshots
/public/bundle.*

1
.npmrc Normal file
View file

@ -0,0 +1 @@
save-exact=true

15
.prettierrc Normal file
View file

@ -0,0 +1,15 @@
{
"arrowParens": "always",
"bracketSpacing": true,
"endOfLine": "lf",
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"printWidth": 80,
"proseWrap": "preserve",
"requirePragma": false,
"semi": true,
"singleQuote": true,
"tabWidth": 4,
"trailingComma": "es5",
"useTabs": true
}

79
README.md Normal file
View file

@ -0,0 +1,79 @@
# svelte-oidc
An Oidc Client Component for Svelte.
[Try out the demo](https://darrelopry.com/svelte-oidc/)
## Getting Started
Setup an OIDC Sever
* https://www.ory.sh/
* https://www.keycloak.org/
* https://www.okta.com/
* http://auth0.com/
Get the authority and client_id
`npm install @dopry/svelte-oidc`
App.svelte
```
# App.svelte
import {
OidcContext,
authError,
authToken,
idToken,
isAuthenticated,
isLoading,
login,
logout,
userInfo,
} from '@dopry/svelte-oidc';
</script>
<OidcContext domain="dev-hvw40i79.auth0.com" client_id="aOijZt2ug6Ovgzp0HXdF23B6zxwA6PaP">
<button on:click|preventDefault='{() => login() }'>Login</button>
<button on:click|preventDefault='{() => logout() }'>Logout</button><br />
<pre>isLoading: {$isLoading}</pre>
<pre>isAuthenticated: {$isAuthenticated}</pre>
<pre>authToken: {$authToken}</pre>
<pre>idToken: {$authToken}</pre>
<pre>userInfo: {JSON.stringify($userInfo, null, 2)}</pre>
<pre>authError: {$authError}</pre>
</OidcContext>
```
## Docs
### Components
* OidcContext - component to initiate the OIDC client. You only need one instance in your DOM tree at the root.
Attributes:
* authority - OIDC Authority/issuer/base url for .well-known/openid-configuration
* client_id - OAuth ClientId
* redirect_uri - default: window.location.href
* post_logout_redirect_uri - override the default url that OIDC will redirect to after logout. default: window.location.href
### Functions
* login(preseveRoute = true, callback_url = null) - begin a user login.
* logout(logout_url = null) - logout a user.
* refreshToken - function to refresh a token.
### Stores
* isLoading - if true OIDC Context is still loading.
* isAuthenticated - true if user is currently authenticated
* authToken - api token
* userInfo - the currently logged in user's info from OIDC
* authError - the last authentication error.
### Constants
* OIDC_CONTEXT_CALLBACK_URL,
* OIDC_CONTEXT_CLIENT_PROMISE - key for the OIDC client in setContext/getContext.
* OIDC_CONTEXT_LOGOUT_URL,
## Release
**use semver**
npm publish
npm showcase:build
npm showcase:publish

1
cypress.json Normal file
View file

@ -0,0 +1 @@
{}

View file

@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View file

@ -0,0 +1,7 @@
/// <reference types="Cypress" />
context('Actions', () => {
beforeEach(() => {
cy.visit('http://localhost:5000');
});
});

18
cypress/plugins/index.js Normal file
View file

@ -0,0 +1,18 @@
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
// eslint-disable-next-line no-unused-vars
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
};

View file

@ -0,0 +1,28 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add("login", (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
// For Cypress Testing Library
import '@testing-library/cypress/add-commands';

20
cypress/support/index.js Normal file
View file

@ -0,0 +1,20 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')

6432
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

77
package.json Normal file
View file

@ -0,0 +1,77 @@
{
"name": "@dopry/svelte-oidc",
"version": "0.0.0",
"repository": "https://github.com/dopry/svelte-oidc",
"description": "Svelte OIDC Component Library",
"keywords": [
"svelte"
],
"license": "MIT",
"main": "dist/index.min.js",
"module": "dist/index.min.mjs",
"scripts": {
"build": "rollup -c",
"cy:open": "cypress open",
"cy:run": "cypress run",
"showcase:publish": "gh-pages -d public",
"showcase:build": "rollup -c rollup.config.showcase.js",
"showcase:dev": "rollup -c rollup.config.showcase.js -w ",
"lint": "eslint --color --ignore-path .gitignore .",
"prepublishOnly": "npm run build",
"start": "sirv public",
"test": "start-server-and-test showcase:dev http://localhost:5000 cy:run"
},
"browserslist": [
"defaults"
],
"files": [
"src",
"dist"
],
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.{html, css, scss, stylus, js, ts, json, yml, md}": [
"prettier --write",
"git add"
],
"*.{js, svelte}": [
"eslint --fix",
"git add"
]
},
"svelte": "src/components/components.module.js",
"dependencies": {
"oidc-client": "github:dopry/oidc-client-js#merge-settings"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "7.0.0",
"@rollup/plugin-replace": "2.3.0",
"@testing-library/cypress": "5.0.2",
"autoprefixer": "9.7.3",
"cypress": "3.8.2",
"eslint": "6.8.0",
"eslint-config-prettier": "6.9.0",
"eslint-plugin-cypress": "2.8.1",
"eslint-plugin-svelte3": "2.7.3",
"gh-pages": "2.2.0",
"lint-staged": "9.5.0",
"postcss": "7.0.26",
"postcss-load-config": "2.1.0",
"prettier": "1.19.1",
"rollup": "1.29.0",
"rollup-plugin-babel": "4.3.3",
"rollup-plugin-commonjs": "10.1.0",
"rollup-plugin-livereload": "1.0.4",
"rollup-plugin-node-resolve": "5.2.0",
"rollup-plugin-svelte": "5.1.1",
"rollup-plugin-terser": "5.2.0",
"sirv-cli": "0.4.5",
"start-server-and-test": "1.10.6",
"svelte": "3.16.7",
"svelte-preprocess": "3.3.0"
}
}

7
public/base.css Normal file
View file

@ -0,0 +1,7 @@
html {
font-family: sans-serif;
}
h1 {
font-family: sans-serif;
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

18
public/index.html Normal file
View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf8" />
<meta name="viewport" content="width=device-width" />
<link href="favicon.png" rel="icon" type="image/png">
<title>svelte-oidc demo</title>
<link rel="stylesheet" href="materialize.min.css" />
</head>
<body>
<div class="container">
<h1>@dopry/svelte-oidc demo</h1>
<a href="https://github.com/dopry/svelte-oidc" class="github-corner" aria-label="View source on GitHub"><svg width="80" height="80" viewBox="0 0 250 250" style="fill:#151513; color:#fff; position: absolute; top: 0; border: 0; right: 0;" aria-hidden="true"><path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path><path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path><path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path></svg></a><style>.github-corner:hover .octo-arm{animation:octocat-wave 560ms ease-in-out}@keyframes octocat-wave{0%,100%{transform:rotate(0)}20%,60%{transform:rotate(-25deg)}40%,80%{transform:rotate(10deg)}}@media (max-width:500px){.github-corner:hover .octo-arm{animation:none}.github-corner .octo-arm{animation:octocat-wave 560ms ease-in-out}}</style>
<script src="bundle.js"></script>
</div>
</body>
</html>

13
public/materialize.min.css vendored Normal file

File diff suppressed because one or more lines are too long

32
rollup.config.js Normal file
View file

@ -0,0 +1,32 @@
import { terser } from 'rollup-plugin-terser';
import commonjs from 'rollup-plugin-commonjs';
import pkg from './package.json';
import resolve from 'rollup-plugin-node-resolve';
import svelte from 'rollup-plugin-svelte';
const name = pkg.name
.replace(/^(@\S+\/)?(svelte-)?(\S+)/, '$3')
.replace(/^\w/, (m) => m.toUpperCase())
.replace(/-\w/g, (m) => m[1].toUpperCase());
export default {
input: 'src/components/components.module.js',
output: [
{ file: pkg.module, format: 'es', sourcemap: true, name },
{ file: pkg.main, format: 'umd', sourcemap: true, name }
],
plugins: [
svelte(),
resolve({
browser: true,
dedupe: (importee) =>
importee === 'svelte' || importee.startsWith('svelte/'),
}),
commonjs({
include: ['node_modules/**'],
}),
terser(),
]
};

66
rollup.config.showcase.js Normal file
View file

@ -0,0 +1,66 @@
import replace from '@rollup/plugin-replace';
import pkg from './package.json';
import commonjs from 'rollup-plugin-commonjs';
import livereload from 'rollup-plugin-livereload';
import resolve from 'rollup-plugin-node-resolve';
import svelte from 'rollup-plugin-svelte';
const production = !process.env.ROLLUP_WATCH;
const defaultRedirectUri = production ? 'https://darrelopry.com/svelte-auth0' : 'http://localhost:5000/';
const defaultPostLogoutRedirectUri = production ? 'https://darrelopry.com/svelte-auth0' : 'http://localhost:5000/';
export default {
input: 'src/main.js',
output: { sourcemap: true, format: 'iife', name: 'app', file: 'public/bundle.js' },
plugins: [
replace({
'process.env.OIDC_ISSUER': process.env.OIDC_ISSUER || "https://dev-hvw40i79.auth0.com",
'process.env.OIDC_CLIENT_ID': process.env.OIDC_CLIENT_ID || "aOijZt2ug6Ovgzp0HXdF23B6zxwA6PaP",
'process.env.OIDC_REDIRECT_URI': process.env.OIDC_REDIRECT_URI || defaultRedirectUri,
'process.env.OIDC_POST_LOGOUT_REDIRECT_URI': process.env.OIDC_POST_LOGOUT_REDIRECT_URI || defaultPostLogoutRedirectUri,
'pkg.version': pkg.version
}),
svelte({ dev: true }),
resolve({
browser: true,
dedupe: (importee) =>
importee === 'svelte' || importee.startsWith('svelte/'),
}),
commonjs({
include: ['node_modules/**'],
}),
// In dev mode, call `npm run start` once
// the bundle has been generated
!production && serve(),
// Watch the `public` directory and refresh the
// browser on changes when not in production
!production && livereload('public'),
],
watch: {
clearScreen: false,
},
};
function serve() {
let started = false;
return {
writeBundle() {
if (!started) {
started = true;
require('child_process').spawn(
'npm',
['run', 'start', '--', '--dev'],
{
stdio: ['ignore', 'inherit', 'inherit'],
shell: true,
}
);
}
},
};
}

39
src/App.svelte Normal file
View file

@ -0,0 +1,39 @@
<script>
import {
OidcContext,
authError,
idToken,
accessToken,
isAuthenticated,
isLoading,
login,
logout,
userInfo,
} from './components/components.module.js';
</script>
<div class="container">
<OidcContext
issuer="process.env.OIDC_ISSUER"
client_id="process.env.OIDC_CLIENT_ID"
redirect_uri="process.env.OIDC_REDIRECT_URI"
post_logout_redirect_uri="process.env.OIDC_POST_LOGOUT_REDIRECT_URI"
>
<button class="btn" on:click|preventDefault='{() => login() }'>Login</button>
<button class="btn" on:click|preventDefault='{() => logout() }'>Logout</button>
<table>
<thead>
<tr><th style="width: 20%;">store</th><th style="width: 80%;">value</th></tr>
</thead>
<tbody>
<tr><td>isLoading</td><td>{$isLoading}</td></tr>
<tr><td>isAuthenticated</td><td>{$isAuthenticated}</td></tr>
<tr><td>accessToken</td><td>{$accessToken}</td></tr>
<tr><td>idToken</td><td style="word-break: break-all;">{$idToken}</td></tr>
<tr><td>userInfo</td><td><pre>{JSON.stringify($userInfo, null, 2)}<pre></td></tr>
<tr><td>authError</td><td>{$authError}</td></tr>
</tbody>
</table>
</OidcContext>
</div>

View file

@ -0,0 +1,111 @@
<script>
import oidcClient from 'oidc-client';
const { UserManager } = oidcClient;
import { onMount, onDestroy, setContext, getContext } from 'svelte';
import {
OIDC_CONTEXT_REDIRECT_URI,
OIDC_CONTEXT_CLIENT_PROMISE,
OIDC_CONTEXT_POST_LOGOUT_REDIRECT_URI,
idToken,
accessToken,
isAuthenticated,
isLoading,
authError,
userInfo
} from './oidc';
// props.
export let issuer;
export let client_id;
export let redirect_uri;
export let post_logout_redirect_uri;
setContext(OIDC_CONTEXT_REDIRECT_URI, redirect_uri);
setContext(OIDC_CONTEXT_POST_LOGOUT_REDIRECT_URI, post_logout_redirect_uri);
// getContext doesn't seem to return a value in OnMount, so we'll pass the oidcPromise around by reference.
const settings = {
authority: issuer,
client_id,
response_type: 'id_token token',
redirect_uri,
post_logout_redirect_uri,
response_type: 'code',
scope: 'openid profile email',
automaticSilentRenew: true,
};
if (issuer.includes('auth0.com')) {
settings.metadata = {
// added to overcome missing value in auth0 .well-known/openid-configuration
// see: https://github.com/IdentityModel/oidc-client-js/issues/1067
// see: https://github.com/IdentityModel/oidc-client-js/pull/1068
end_session_endpoint: `process.env.OIDC_ISSUER/v2/logout?client_id=process.env.OIDC_CLIENT_ID`,
};
}
const userManager = new UserManager(settings);
userManager.events.addUserLoaded(function () {
const user = userManager.getUser();
accessToken.set(user.access_token);
idToken.set(user.id_token);
userInfo.set(user.profile);
});
userManager.events.addUserUnloaded(function () {
idToken.set('');
accessToken.set('');
});
userManager.events.addSilentRenewError(function (e) {
authError.set(`silentRenewError: ${e.message}`);
});
let oidcPromise = Promise.resolve(userManager);
setContext(OIDC_CONTEXT_CLIENT_PROMISE, oidcPromise);
async function handleOnMount() {
// on run onMount after oidc
const oidc = await oidcPromise;
// Not all browsers support this, please program defensively!
const params = new URLSearchParams(window.location.search);
// Check if something went wrong during login redirect
// and extract the error message
if (params.has('error')) {
authError.set(new Error(params.get('error_description')));
}
// if code then login success
if (params.has('code')) {
// handle the redirect response.
const response = await oidc.signinRedirectCallback();
let state = (response && response.state) || {}
// Can be smart here and redirect to original path instead of root
const url = state && state.targetUrl ? state.targetUrl : window.location.pathname;
state = { ...state, isRedirectCallback: true };
// redirect to the last page we were on when login was configured if it was passed.
history.replaceState(state, "", url);
// location.href = url;
// clear errors on login.
authError.set(null);
}
const user = await oidc.getUser();
isAuthenticated.set(!!user);
accessToken.set(user.access_token);
idToken.set(user.id_token);
userInfo.set(user.profile);
isLoading.set(false);
}
async function handleOnDestroy() {}
onMount(handleOnMount);
onDestroy(handleOnDestroy);
</script>
<slot></slot>

View file

@ -0,0 +1,3 @@
export * from './oidc'
export { default as OidcContext } from './OidcContext.svelte';

62
src/components/oidc.js Normal file
View file

@ -0,0 +1,62 @@
import { writable } from 'svelte/store';
import { getContext } from 'svelte';
/**
* Stores
*/
export const isLoading = writable(true);
export const isAuthenticated = writable(false);
export const accessToken = writable('');
export const idToken = writable('');
export const userInfo = writable({});
export const authError = writable(null);
/**
* Context Keys
*
* using an object literal means the keys are guaranteed not to conflict in any circumstance (since an object only has
* referential equality to itself, i.e. {} !== {} whereas "x" === "x"), even when you have multiple different contexts
* operating across many component layers.
*/
export const OIDC_CONTEXT_CLIENT_PROMISE = {};
export const OIDC_CONTEXT_REDIRECT_URI = {};
export const OIDC_CONTEXT_POST_LOGOUT_REDIRECT_URI = {};
/**
* Refresh the accessToken store.
*/
export async function refreshToken() {
const oidc = await getContext(OIDC_CONTEXT_CLIENT_PROMISE)
const token = await oidc.signinSilent();
accessToken.set(token.accessToken);
idToken.set(token.idToken);
}
/**
* Initiate Register/Login flow.
*
* @param {boolean} preserveRoute - store current location so callback handler will navigate back to it.
* @param {string} callback_url - explicit path to use for the callback.
*/
export async function login(preserveRoute = true, callback_url = null) {
const oidc = await getContext(OIDC_CONTEXT_CLIENT_PROMISE)
const redirect_uri = callback_url || getContext(OIDC_CONTEXT_REDIRECT_URI) || window.location.href;
// try to keep the user on the same page from which they triggered login. If set to false should typically
// cause redirect to /.
const appState = (preserveRoute) ? { pathname: window.location.pathname, search: window.location.search } : {}
await oidc.signinRedirect({ redirect_uri, appState });
}
/**
* Log out the current user.
*
* @param {string} logout_url - specify the url to return to after login.
*/
export async function logout(logout_url = null) {
// getContext(OIDC_CONTEXT_CLIENT_PROMISE) returns a promise.
const oidc = await getContext(OIDC_CONTEXT_CLIENT_PROMISE)
const returnTo = logout_url || getContext(OIDC_CONTEXT_POST_LOGOUT_REDIRECT_URI) || window.location.href;
accessToken.set('');
oidc.signoutRedirect({ returnTo });
}

8
src/main.js Normal file
View file

@ -0,0 +1,8 @@
import App from './App.svelte';
const app = new App({
target: document.body,
props: {},
});
export default app;