├── .dockerignore ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── Dockerfile ├── LICENSE.txt ├── README.md ├── azure-pipelines.yml ├── npm-shrinkwrap.json ├── package.json ├── rollup.config.js ├── src ├── client.ts ├── components │ ├── CloseIcon.svelte │ ├── ErrorUi.svelte │ ├── HandshakeLogoIcon.svelte │ └── TextInput.svelte ├── config.ts ├── hns.ts ├── node_modules │ └── images │ │ └── successkid.jpg ├── oidc.ts ├── providers │ └── AnnouncementContextProvider │ │ ├── AnnouncementContextProvider.svelte │ │ └── types.ts ├── redis-adapter.ts ├── redis-client.ts ├── routes │ ├── _error.svelte │ ├── _layout.svelte │ ├── login │ │ ├── [uid] │ │ │ └── challenge.svelte │ │ └── index.svelte │ ├── oidc-provider.ts │ └── validate.ts ├── server.ts ├── skip-policy.ts ├── startup.ts └── template.html ├── static ├── RobotoMono-regular.woff ├── RobotoVariable-subset.woff2 ├── favicon.png └── global.css └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | yarn-error.log 4 | /__sapper__/ 5 | Dockerfile 6 | .vscode 7 | README -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | rollup.config.js 3 | svelte.config.js -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | parserOptions: { 5 | ecmaVersion: 2017, 6 | tsconfigRootDir: __dirname, 7 | project: "./tsconfig.json", 8 | }, 9 | rules: { 10 | "import/no-mutable-exports": 0, 11 | "no-labels": 0, 12 | "no-restricted-syntax": 0, 13 | "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "_" }] 14 | }, 15 | plugins: ["@typescript-eslint", "svelte3"], 16 | extends: [ 17 | "plugin:@typescript-eslint/recommended", 18 | "plugin:eslint-comments/recommended", 19 | "plugin:promise/recommended", 20 | "prettier", 21 | "prettier/@typescript-eslint", 22 | ], 23 | overrides: [ 24 | { 25 | files: ["**/*.svelte"], 26 | processor: "svelte3/svelte3", 27 | }, 28 | ], 29 | env: { 30 | es6: true 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /node_modules/ 3 | /src/node_modules/@sapper/ 4 | yarn-error.log 5 | /__sapper__/ 6 | 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "useTabs": false, 5 | "tabWidth": 2, 6 | "printWidth": 100, 7 | "overrides": [ 8 | { 9 | "files": "*.ts", 10 | "options": { 11 | "parser": "typescript" 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | {"recommendations": ["svelte.svelte-vscode"]} -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorCustomizations": { 3 | "titleBar.activeBackground": "#ff69b4", 4 | "titleBar.activeForeground": "#660033", 5 | "titleBar.inactiveBackground": "#ff99cc" 6 | }, 7 | 8 | "eslint.format.enable": true, 9 | "files.insertFinalNewline": true, 10 | 11 | "editor.tabSize": 2, 12 | "editor.insertSpaces": true, 13 | "editor.formatOnSave": true, 14 | "editor.defaultFormatter": "esbenp.prettier-vscode", 15 | "editor.codeActionsOnSave": { 16 | "source.fixAll.eslint": true, 17 | "source.organizeImports": true 18 | }, 19 | 20 | "[ts]": { 21 | "editor.defaultFormatter": "esbenp.prettier-vscode" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:15-alpine as base 2 | 3 | ARG NODE_ENV=production 4 | ENV NODE_ENV $NODE_ENV 5 | ARG PORT=8080 6 | ENV PORT $PORT 7 | 8 | WORKDIR /opt/node_app 9 | 10 | FROM base as deps 11 | RUN apk --no-cache --update --virtual build-dependencies add \ 12 | python \ 13 | make \ 14 | g++ 15 | 16 | COPY --chown=node:node ./package.json ./ 17 | COPY --chown=node:node ./npm-shrinkwrap.json ./ 18 | RUN npm ci --also=dev \ 19 | && npm cache clean --force 20 | 21 | COPY --chown=node:node ./ ./ 22 | RUN npm run build 23 | 24 | FROM base as release 25 | RUN apk add --no-cache tini 26 | USER node 27 | WORKDIR /opt/node_app/ 28 | COPY --chown=node:node --from=deps /opt/node_app/__sapper__/build ./__sapper__/build 29 | COPY --chown=node:node --from=deps /opt/node_app/node_modules ./node_modules 30 | COPY --chown=node:node --from=deps /opt/node_app/static ./static 31 | 32 | 33 | CMD [ "/sbin/tini", "node", "./__sapper__/build" ] 34 | 35 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2021 namebase 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Handshake OIDC Provider 2 | 3 | ## Requirements 4 | 5 | NodeJS 15+ 6 | 7 | ``` 8 | nvm install 15.8.0 9 | nvm use 15.8.0 10 | ``` 11 | 12 | ## Running the web app 13 | 14 | ``` 15 | npm run dev 16 | ``` 17 | 18 | ## Route 19 | 20 | [src/routes/oidc-provider.ts](src/routes/oidc-provider.ts) 21 | 22 | ## Config 23 | 24 | [src/oidc.ts](src/oidc.ts) 25 | 26 | ## Crypto 27 | 28 | [src/hns.ts](src/hns.ts) 29 | 30 | ## Sources 31 | 32 | Visit https://github.com/panva/node-oidc-provider/blob/master/docs/README.md for more details on OIDC Provider concepts and user flow. 33 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | # Node.js 2 | # Build a general Node.js project with npm. 3 | # Add steps that analyze code, save build artifacts, deploy, and more: 4 | # https://docs.microsoft.com/azure/devops/pipelines/languages/javascript 5 | resources: 6 | repositories: 7 | - repository: repoDeploy 8 | type: git 9 | name: namernews/deploy 10 | 11 | trigger: 12 | branches: 13 | include: 14 | - '*' 15 | batch: true 16 | 17 | pool: 18 | vmImage: ubuntu-16.04 19 | 20 | stages: 21 | - stage: build 22 | displayName: Build 23 | jobs: 24 | - job: Build 25 | pool: 26 | vmImage: ubuntu-16.04 27 | steps: 28 | - template: docker.yml@repoDeploy 29 | parameters: 30 | imagename: 'hs-id-provider' 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "handshake-oidc", 3 | "description": "OIDC provider using handshake name resolution", 4 | "version": "0.0.1", 5 | "config": { 6 | "deploymentName": "oidc", 7 | "namespace": "oidc" 8 | }, 9 | "scripts": { 10 | "dev": "NODE_ENV=development PORT=8080 sapper dev", 11 | "build": "sapper build --legacy", 12 | "export": "sapper export --legacy", 13 | "start": "node __sapper__/build", 14 | "validate": "svelte-check --ignore src/node_modules/@sapper", 15 | "dev:remote": "npm run dev:remote:shell -- --run npm run dev", 16 | "dev:remote:shell": "telepresence --swap-deployment $npm_package_config_deploymentName --namespace $npm_package_config_namespace --logfile /dev/null" 17 | }, 18 | "dependencies": { 19 | "@sentry/integrations": "^6.1.0", 20 | "@sentry/node": "^6.1.0", 21 | "compression": "^1.7.4", 22 | "connect-redis": "^5.1.0", 23 | "cookie-parser": "^1.4.5", 24 | "cors": "^2.8.5", 25 | "dotenv": "^8.2.0", 26 | "express": "^4.17.1", 27 | "express-rate-limit": "^5.2.5", 28 | "express-session": "^1.17.1", 29 | "hdns": "^0.7.0", 30 | "helmet": "^4.4.1", 31 | "ioredis": "^4.22.0", 32 | "morgan": "^1.10.0", 33 | "oidc-provider": "^6.31.0", 34 | "polka": "next", 35 | "sirv": "^1.0.11" 36 | }, 37 | "devDependencies": { 38 | "@babel/core": "^7.12.13", 39 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 40 | "@babel/plugin-transform-runtime": "^7.12.15", 41 | "@babel/preset-env": "^7.12.13", 42 | "@babel/runtime": "^7.12.13", 43 | "@rollup/plugin-babel": "^5.2.3", 44 | "@rollup/plugin-commonjs": "^17.1.0", 45 | "@rollup/plugin-node-resolve": "^11.1.1", 46 | "@rollup/plugin-replace": "^2.3.4", 47 | "@rollup/plugin-typescript": "^8.1.1", 48 | "@rollup/plugin-url": "^6.0.0", 49 | "@tsconfig/svelte": "^1.0.10", 50 | "@types/compression": "^1.7.0", 51 | "@types/express-session": "^1.17.3", 52 | "@types/ioredis": "^4.22.0", 53 | "@types/node": "^14.14.25", 54 | "@types/polka": "^0.5.2", 55 | "rollup": "^2.38.5", 56 | "rollup-plugin-svelte": "^7.1.0", 57 | "rollup-plugin-terser": "^7.0.2", 58 | "sapper": "^0.28.10", 59 | "svelte": "^3.32.3", 60 | "svelte-check": "^1.1.34", 61 | "svelte-preprocess": "^4.6.1", 62 | "tslib": "^2.1.0", 63 | "typescript": "^4.1.5" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import resolve from '@rollup/plugin-node-resolve'; 4 | import replace from '@rollup/plugin-replace'; 5 | import typescript from '@rollup/plugin-typescript'; 6 | import url from '@rollup/plugin-url'; 7 | import path from 'path'; 8 | import svelte from 'rollup-plugin-svelte'; 9 | import { terser } from 'rollup-plugin-terser'; 10 | import config from 'sapper/config/rollup.js'; 11 | import sveltePreprocess from 'svelte-preprocess'; 12 | import pkg from './package.json'; 13 | 14 | const mode = process.env.NODE_ENV; 15 | const dev = mode === 'development'; 16 | const legacy = !!process.env.SAPPER_LEGACY_BUILD; 17 | 18 | const onwarn = (warning, onwarn) => 19 | (warning.code === 'MISSING_EXPORT' && /'preload'/.test(warning.message)) || 20 | (warning.code === 'CIRCULAR_DEPENDENCY' && /[/\\]@sapper[/\\]/.test(warning.message)) || 21 | warning.code === 'THIS_IS_UNDEFINED' || 22 | onwarn(warning); 23 | 24 | export default { 25 | client: { 26 | input: config.client.input().replace(/\.js$/, '.ts'), 27 | output: config.client.output(), 28 | plugins: [ 29 | replace({ 30 | 'process.browser': true, 31 | 'process.env.NODE_ENV': JSON.stringify(mode), 32 | }), 33 | svelte({ 34 | preprocess: sveltePreprocess({ sourceMap: dev }), 35 | compilerOptions: { 36 | dev, 37 | hydratable: true, 38 | }, 39 | }), 40 | url({ 41 | sourceDir: path.resolve(__dirname, 'src/node_modules/images'), 42 | publicPath: '/client/', 43 | }), 44 | resolve({ 45 | browser: true, 46 | dedupe: ['svelte'], 47 | }), 48 | commonjs(), 49 | typescript({ sourceMap: dev }), 50 | 51 | legacy && 52 | babel({ 53 | extensions: ['.js', '.mjs', '.html', '.svelte'], 54 | babelHelpers: 'runtime', 55 | exclude: ['node_modules/@babel/**'], 56 | presets: [ 57 | [ 58 | '@babel/preset-env', 59 | { 60 | targets: '> 0.25%, not dead', 61 | }, 62 | ], 63 | ], 64 | plugins: [ 65 | '@babel/plugin-syntax-dynamic-import', 66 | [ 67 | '@babel/plugin-transform-runtime', 68 | { 69 | useESModules: true, 70 | }, 71 | ], 72 | ], 73 | }), 74 | 75 | !dev && 76 | terser({ 77 | module: true, 78 | }), 79 | ], 80 | 81 | preserveEntrySignatures: false, 82 | onwarn, 83 | }, 84 | 85 | server: { 86 | input: { server: config.server.input().server.replace(/\.js$/, '.ts') }, 87 | output: config.server.output(), 88 | plugins: [ 89 | replace({ 90 | 'process.browser': false, 91 | 'process.env.NODE_ENV': JSON.stringify(mode), 92 | }), 93 | svelte({ 94 | preprocess: sveltePreprocess({ sourceMap: dev }), 95 | compilerOptions: { 96 | dev, 97 | generate: 'ssr', 98 | hydratable: true, 99 | }, 100 | emitCss: false, 101 | }), 102 | url({ 103 | sourceDir: path.resolve(__dirname, 'src/node_modules/images'), 104 | publicPath: '/client/', 105 | emitFiles: false, // already emitted by client build 106 | }), 107 | resolve({ 108 | dedupe: ['svelte'], 109 | }), 110 | commonjs(), 111 | typescript({ sourceMap: dev }), 112 | ], 113 | external: Object.keys(pkg.dependencies).concat(require('module').builtinModules), 114 | 115 | preserveEntrySignatures: 'strict', 116 | onwarn, 117 | }, 118 | }; 119 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import * as sapper from '@sapper/app'; 2 | sapper.start({ target: document.querySelector('#sapper') }); 3 | -------------------------------------------------------------------------------- /src/components/CloseIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/ErrorUi.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | 9 | 10 | Login | Namebase | Error 11 | 12 | 13 |
14 |

404

15 |

An error occurred. But we caught it.

16 |
We don't know how to fix this right now, so maybe try again later.
17 | {#if IS_DEVELOPMENT && error && error.stack} 18 |
19 |
{error.stack}
20 |
21 | {/if} 22 |
23 | 24 | 75 | -------------------------------------------------------------------------------- /src/components/HandshakeLogoIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | -------------------------------------------------------------------------------- /src/components/TextInput.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 32 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | 3 | dotenv.config(); 4 | 5 | export const isDevelopment: boolean = process.env.NODE_ENV === 'development'; 6 | export const oidc = { 7 | host: process.env.PUBLIC_HOST, 8 | oidc_provider_secrets: JSON.parse(process.env.OIDC_PROVIDER_SECRETS || '[]'), 9 | oidc_provider_clients: JSON.parse(process.env.OIDC_PROVIDER_CLIENTS || '[]'), 10 | jwks: <[]>JSON.parse(process.env.OIDC_JWKS || '[]'), 11 | }; 12 | 13 | export const config = { 14 | session_secret: process.env.SESSION_SECRET || 'session secret', 15 | }; 16 | export const port = process.env.PORT || 8080; 17 | 18 | export const redis = { 19 | host: process.env.REDIS_HOST || 'redis', 20 | port: (process.env.REDIS_PORT), 21 | password: process.env.REDIS_PASSWORD, 22 | }; 23 | export const sentry = process.env.SENTRY_DSN; 24 | 25 | export const hsdResolvers = ['103.196.38.38', '103.196.38.39']; 26 | -------------------------------------------------------------------------------- /src/hns.ts: -------------------------------------------------------------------------------- 1 | import hdns from 'hdns'; 2 | import { hsdResolvers } from './config'; 3 | const { subtle } = require('crypto').webcrypto; 4 | 5 | hdns.setServers(hsdResolvers); 6 | 7 | function encodeMessage(str) { 8 | return new TextEncoder().encode(str); 9 | } 10 | 11 | function str2ab(str) { 12 | const buf = new ArrayBuffer(str.length); 13 | const bufView = new Uint8Array(buf); 14 | for (let i = 0, strLen = str.length; i < strLen; i++) { 15 | bufView[i] = str.charCodeAt(i); 16 | } 17 | return buf; 18 | } 19 | function ab2str(buf) { 20 | return String.fromCharCode.apply(null, new Uint8Array(buf)); 21 | } 22 | 23 | export function btoa(str) { 24 | return Buffer.from(str, 'binary').toString('base64'); 25 | } 26 | export function atob(str) { 27 | return Buffer.from(str, 'base64').toString('binary'); 28 | } 29 | 30 | export async function getRecordsAsync(id) { 31 | const regex = /([^;]+)=([^;]*)/g; 32 | try { 33 | // @ts-ignore 34 | const txts: string[] = await hdns.resolveTxt(id, { all: true }); 35 | const parsedRecords = txts 36 | .map((txt) => { 37 | const params: Record = {}; 38 | let match: RegExpExecArray = null; 39 | while ((match = regex.exec(txt))) { 40 | params[match[1]] = match[2]; 41 | } 42 | return params; 43 | }) 44 | .sort((a, b) => { 45 | if (!b.v) { 46 | return -1; 47 | } 48 | return a.v > b.v ? -1 : 1; 49 | }); 50 | return parsedRecords; 51 | } catch (e) { 52 | console.warn('invalid hns check', e); 53 | return []; 54 | } 55 | } 56 | 57 | export async function verifyFingerPrint(fingerprint, publicKey) { 58 | const fp = await generateFingerprint(publicKey); 59 | return fp === fingerprint; 60 | } 61 | 62 | export function importCryptoKey(pem) { 63 | const pemHeader = '-----BEGIN PUBLIC KEY-----'; 64 | const pemFooter = '-----END PUBLIC KEY-----'; 65 | const pemContents = pem.substring(pemHeader.length, pem.length - pemFooter.length); 66 | const binaryDerString = atob(pemContents); 67 | const binaryDer = str2ab(binaryDerString); 68 | return subtle.importKey( 69 | 'spki', 70 | binaryDer, 71 | { 72 | name: 'RSA-PSS', 73 | hash: 'SHA-512', 74 | }, 75 | true, 76 | ['verify'], 77 | ); 78 | } 79 | 80 | export function importCryptoPrivateKey(pem) { 81 | const pemHeader = '-----BEGIN PRIVATE KEY-----'; 82 | const pemFooter = '-----END PRIVATE KEY-----'; 83 | const pemContents = pem.substring(pemHeader.length, pem.length - pemFooter.length); 84 | // base64 decode the string to get the binary data 85 | const binaryDerString = atob(pemContents); 86 | // convert from a binary string to an ArrayBuffer 87 | const binaryDer = this._str2ab(binaryDerString); 88 | return subtle.importKey( 89 | 'pkcs8', 90 | binaryDer, 91 | { 92 | name: 'RSA-PSS', 93 | // Consider using a 4096-bit key for systems that require long-term security 94 | hash: 'SHA-512', 95 | }, 96 | true, 97 | ['sign'], 98 | ); 99 | } 100 | 101 | export async function sign(privateKey, data) { 102 | const signature = await subtle.sign( 103 | { 104 | name: 'RSA-PSS', 105 | saltLength: 64, 106 | }, 107 | privateKey, 108 | encodeMessage(data), 109 | ); 110 | const exportedAsString = ab2str(signature); 111 | const exportedAsBase64 = btoa(exportedAsString); 112 | return exportedAsBase64; 113 | } 114 | 115 | export async function verifySignature(publicKey, signature, data) { 116 | const binaryDerString = atob(signature); 117 | const binaryDer = str2ab(binaryDerString); 118 | return await subtle.verify( 119 | { 120 | name: 'RSA-PSS', 121 | saltLength: 64, 122 | }, 123 | publicKey, 124 | binaryDer, 125 | encodeMessage(data), 126 | ); 127 | } 128 | 129 | export async function generateFingerprint(publicKey) { 130 | const fingerprint = await subtle.digest('SHA-256', new TextEncoder().encode(publicKey)); 131 | const hashArray = Array.from(new Uint8Array(fingerprint)); 132 | const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); // convert bytes to hex string 133 | return hashHex; 134 | } 135 | -------------------------------------------------------------------------------- /src/node_modules/images/successkid.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/namebasehq/handshake-oidc/7ae091ce697fdcfcee8c6ef2f4693aa3d8bfe900/src/node_modules/images/successkid.jpg -------------------------------------------------------------------------------- /src/oidc.ts: -------------------------------------------------------------------------------- 1 | import type { JSONWebKeySet } from 'jose'; 2 | import { JWK, JWKS } from 'jose'; 3 | import { 4 | CanBePromise, 5 | ClientMetadata, 6 | Configuration, 7 | interactionPolicy, 8 | KoaContextWithOIDC, 9 | Provider, 10 | } from 'oidc-provider'; 11 | import { oidc as config } from './config'; 12 | import { RedisAdapter } from './redis-adapter'; 13 | import { skipPolicy } from './skip-policy'; 14 | // create a requestable prompt with no implicit checks 15 | const Prompt = interactionPolicy.Prompt; 16 | const policy = interactionPolicy.base; 17 | 18 | const selectAccount = new Prompt({ 19 | name: 'select_account', 20 | requestable: true, 21 | }); 22 | 23 | // copies the default policy, already has login and consent prompt policies 24 | const interactions = policy(); 25 | // add to index 0, order goes select_account > login > consent 26 | interactions.add(selectAccount, 0); 27 | 28 | function RedisAdapterFactory(name: string) { 29 | return new RedisAdapter(name); 30 | } 31 | export type ClientConfig = ClientMetadata & { audience: string }; 32 | 33 | const audiences = ( 34 | ctx: KoaContextWithOIDC, 35 | sub: string | undefined, 36 | token: any, 37 | use: 'access_token' | 'client_credentials', 38 | ): CanBePromise => { 39 | const client = config.oidc_provider_clients.find((c) => c.client_id == token?.clientId); 40 | return client?.audience; 41 | }; 42 | 43 | const getJWKS = (): JSONWebKeySet => { 44 | const keystore = new JWKS.KeyStore(config.jwks.map((k) => JWK.asKey(k))); 45 | if (keystore.size === 0) { 46 | keystore.generateSync('RSA', 4096, { alg: 'RS512', use: 'sig' }); 47 | keystore.generateSync('RSA', 4096, { alg: 'RS512', use: 'enc' }); 48 | console.log('add these keys to OIDC_JWKS\r\n', keystore.toJWKS(true)); 49 | } 50 | return keystore.toJWKS(true); 51 | }; 52 | 53 | const configuration: Configuration = { 54 | adapter: RedisAdapterFactory, 55 | cookies: { 56 | long: { signed: true, maxAge: 1 * 24 * 60 * 60 * 1000 }, // 1 day in ms 57 | short: { signed: true }, 58 | keys: config.oidc_provider_secrets, 59 | }, 60 | claims: { 61 | profile: [], 62 | }, 63 | interactions: { 64 | policy: interactions, 65 | url(ctx, interaction) { 66 | return `/oidc/interaction/${ctx.oidc.uid}`; 67 | }, 68 | }, 69 | audiences: audiences, 70 | clients: config.oidc_provider_clients, 71 | features: { 72 | devInteractions: { enabled: false }, // defaults to true 73 | deviceFlow: { enabled: true }, // defaults to false 74 | introspection: { enabled: true }, // defaults to false 75 | revocation: { enabled: true }, // defaults to false 76 | rpInitiatedLogout: { 77 | enabled: true, 78 | logoutSource: async (ctx, form) => { 79 | // @param ctx - koa request context 80 | // @param form - form source (id="op.logoutForm") to be embedded in the page and submitted by 81 | // the End-User 82 | ctx.body = ` 83 | 84 | Logout Request 85 | 86 | 87 |
88 | ${form} 89 | 90 | 91 |
92 | 93 | `; 94 | }, 95 | }, 96 | }, 97 | formats: { AccessToken: 'jwt' }, 98 | async findAccount(ctx, id) { 99 | return { 100 | accountId: id, 101 | async claims(use, scope) { 102 | return { sub: id }; 103 | }, 104 | }; 105 | }, 106 | }; 107 | 108 | const oidc = new Provider(`https://${config.host}/`, configuration); 109 | oidc.proxy = true; 110 | 111 | skipPolicy(oidc); 112 | 113 | export default oidc; 114 | -------------------------------------------------------------------------------- /src/providers/AnnouncementContextProvider/AnnouncementContextProvider.svelte: -------------------------------------------------------------------------------- 1 | 44 | 45 | 76 | 77 | {#if bannerMessage} 78 | 90 | {/if} 91 | 92 | 93 | 119 | -------------------------------------------------------------------------------- /src/providers/AnnouncementContextProvider/types.ts: -------------------------------------------------------------------------------- 1 | export type AnnouncementType = 'info' | 'error'; 2 | 3 | export type AnnouncementRequest = { 4 | type?: AnnouncementType, 5 | message: string, 6 | } 7 | 8 | export type AnnouncementContext = { 9 | announce: (request: AnnouncementRequest) => void; 10 | }; 11 | -------------------------------------------------------------------------------- /src/redis-adapter.ts: -------------------------------------------------------------------------------- 1 | import { isEmpty } from 'lodash'; 2 | import type { Adapter } from 'oidc-provider'; 3 | import { redis } from './redis-client'; 4 | 5 | export const client = redis.createClient({ keyPrefix: 'oidc:' }); 6 | 7 | const consumable = new Set(['AuthorizationCode', 'RefreshToken', 'DeviceCode']); 8 | 9 | function grantKeyFor(id) { 10 | return `grant:${id}`; 11 | } 12 | 13 | function userCodeKeyFor(userCode) { 14 | return `userCode:${userCode}`; 15 | } 16 | 17 | function uidKeyFor(uid) { 18 | return `uid:${uid}`; 19 | } 20 | 21 | export class RedisAdapter implements Adapter { 22 | public name: string; 23 | constructor(name: string) { 24 | this.name = name; 25 | } 26 | 27 | async upsert(id, payload, expiresIn) { 28 | const key = this.key(id); 29 | const store = consumable.has(this.name) 30 | ? { payload: JSON.stringify(payload) } 31 | : JSON.stringify(payload); 32 | 33 | const multi = client.multi(); 34 | // @ts-ignore 35 | multi[consumable.has(this.name) ? 'hmset' : 'set'](key, store); 36 | 37 | if (expiresIn) { 38 | multi.expire(key, expiresIn); 39 | } 40 | 41 | if (payload.grantId) { 42 | const grantKey = grantKeyFor(payload.grantId); 43 | multi.rpush(grantKey, key); 44 | // if you're seeing grant key lists growing out of acceptable proportions consider using LTRIM 45 | // here to trim the list to an appropriate length 46 | const ttl = await client.ttl(grantKey); 47 | if (expiresIn > ttl) { 48 | multi.expire(grantKey, expiresIn); 49 | } 50 | } 51 | 52 | if (payload.userCode) { 53 | const userCodeKey = userCodeKeyFor(payload.userCode); 54 | multi.set(userCodeKey, id); 55 | multi.expire(userCodeKey, expiresIn); 56 | } 57 | 58 | if (payload.uid) { 59 | const uidKey = uidKeyFor(payload.uid); 60 | multi.set(uidKey, id); 61 | multi.expire(uidKey, expiresIn); 62 | } 63 | 64 | await multi.exec(); 65 | } 66 | 67 | async find(id) { 68 | const data = consumable.has(this.name) 69 | ? await client.hgetall(this.key(id)) 70 | : await client.get(this.key(id)); 71 | 72 | if (isEmpty(data)) { 73 | return undefined; 74 | } 75 | 76 | if (typeof data === 'string') { 77 | return JSON.parse(data); 78 | } 79 | const { payload, ...rest } = data; 80 | return { 81 | ...rest, 82 | ...JSON.parse(payload), 83 | }; 84 | } 85 | 86 | async findByUid(uid) { 87 | const id = await client.get(uidKeyFor(uid)); 88 | return this.find(id); 89 | } 90 | 91 | async findByUserCode(userCode) { 92 | const id = await client.get(userCodeKeyFor(userCode)); 93 | return this.find(id); 94 | } 95 | 96 | async destroy(id) { 97 | const key = this.key(id); 98 | await client.del(key); 99 | } 100 | 101 | async revokeByGrantId(grantId) { 102 | // eslint-disable-line class-methods-use-this 103 | const multi = client.multi(); 104 | const tokens = await client.lrange(grantKeyFor(grantId), 0, -1); 105 | tokens.forEach((token) => multi.del(token)); 106 | multi.del(grantKeyFor(grantId)); 107 | await multi.exec(); 108 | } 109 | 110 | async consume(id) { 111 | await client.hset(this.key(id), 'consumed', Math.floor(Date.now() / 1000)); 112 | } 113 | 114 | key(id) { 115 | return `${this.name}:${id}`; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/redis-client.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis'; 2 | import { redis as config } from './config'; 3 | 4 | export const redis = { 5 | createClient: (opt) => new Redis(config.port, config.host, { password: config.password, ...opt }), 6 | }; 7 | -------------------------------------------------------------------------------- /src/routes/_error.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/routes/_layout.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 |
8 | 11 | 12 |
13 |
14 | 15 | 32 | -------------------------------------------------------------------------------- /src/routes/login/[uid]/challenge.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 36 | 37 |

Log in

38 |
39 |
One moment please...
40 |
41 | 42 | 55 | -------------------------------------------------------------------------------- /src/routes/login/index.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 22 | 23 | 24 | Login | Namebase 25 | 26 | 27 | {#if typeof uidHash === 'string'} 28 |
29 |

Log in

30 |
31 |
32 | 33 |
34 | 35 | 45 |
46 |
47 |
48 |
Don't have a Handshake name?
49 | Request one for free 54 |
55 | 63 |
64 |
65 |
66 |
67 | {/if} 68 | 69 | 131 | -------------------------------------------------------------------------------- /src/routes/oidc-provider.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import express, { urlencoded } from 'express'; 3 | import { 4 | atob, 5 | btoa, 6 | getRecordsAsync, 7 | importCryptoKey, 8 | verifyFingerPrint, 9 | verifySignature, 10 | } from '../hns'; 11 | import oidc from '../oidc'; 12 | const router = express.Router(); 13 | const body = urlencoded({ extended: false }); 14 | 15 | declare module 'express-session' { 16 | export interface Session { 17 | state: string; 18 | } 19 | } 20 | 21 | function setNoCache(req, res, next) { 22 | res.set('Pragma', 'no-cache'); 23 | res.set('Cache-Control', 'no-cache, no-store'); 24 | next(); 25 | } 26 | 27 | // initiate auth flow 28 | router.get('/interaction/:uid', setNoCache, async (req, res, next) => { 29 | try { 30 | const { uid, prompt, params, session } = await oidc.interactionDetails(req, res); 31 | const client = await oidc.Client.find(params.client_id); 32 | 33 | if (prompt.name === 'login') { 34 | req.session.state = params.state; 35 | return res.redirect(`/login#${uid}`); 36 | } 37 | 38 | if (prompt.name === 'consent') { 39 | const consent = { 40 | rejectedScopes: [], 41 | rejectedClaims: [], 42 | replace: false, 43 | }; 44 | const result = { consent }; 45 | await oidc.interactionFinished(req, res, result, { mergeWithLastSubmission: true }); 46 | } 47 | } catch (err) { 48 | return next(err); 49 | } 50 | }); 51 | router.post('/interaction/:uid/manager', setNoCache, body, async (req, res, next) => { 52 | const { uid, prompt, params, session } = await oidc.interactionDetails(req, res); 53 | 54 | const id = atob(req.body.id); 55 | const managers = await getRecordsAsync('_idmanager.' + id); 56 | let managerUrl = new URL(`https://id.namebase.io`); 57 | if (managers.length > 0) { 58 | try { 59 | managerUrl = new URL(managers[0].url); 60 | } catch (e) { 61 | console.warn(e); 62 | } 63 | } 64 | 65 | const data = { 66 | callbackUrl: `${req.protocol}://${req.get('host')}/login/${uid}/challenge`, 67 | state: req.session.state, 68 | id, 69 | }; 70 | managerUrl.hash = `#/login?state=${btoa(data.state)}&id=${btoa(data.id)}&callbackUrl=${btoa( 71 | data.callbackUrl, 72 | )}`; 73 | res.redirect(managerUrl.toString()); 74 | }); 75 | // login request 76 | router.post('/interaction/:uid/login', setNoCache, body, async (req, res, next) => { 77 | let result = {}; 78 | try { 79 | const { 80 | prompt: { name }, 81 | params, 82 | } = await oidc.interactionDetails(req, res); 83 | assert.strictEqual(name, 'login'); 84 | const publickey = atob(req.body.publicKey); 85 | const id = atob(req.body.domain).toLowerCase(); 86 | const signed = atob(req.body.signed); 87 | const deviceId = atob(req.body.deviceId); 88 | 89 | const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 90 | 91 | let attempt = 3; 92 | let isFingerprintValid = false; 93 | while (attempt-- !== 0) { 94 | const fingerprintRecords = (await getRecordsAsync(`${deviceId}._auth.${id}`)).filter((r) => 95 | r.fingerprint ? true : false, 96 | ); 97 | isFingerprintValid = 98 | fingerprintRecords.length > 0 && 99 | (await verifyFingerPrint(fingerprintRecords[0].fingerprint, publickey)); 100 | if (isFingerprintValid) { 101 | break; 102 | } else { 103 | await sleep(300); 104 | } 105 | } 106 | 107 | const crypto = await importCryptoKey(publickey); 108 | const isSignatureValid = await verifySignature(crypto, signed, req.session.state); 109 | 110 | if (isFingerprintValid && isSignatureValid) { 111 | const account = { accountId: id }; 112 | 113 | result = { 114 | select_account: {}, // make sure its skipped by the interaction policy since we just logged in 115 | login: { 116 | account: account.accountId, 117 | }, 118 | }; 119 | } else { 120 | result = { 121 | error: 'access_denied', 122 | error_description: 'Invalid credentials', 123 | }; 124 | console.warn( 125 | `Fingerprint or decryption invalid: isFingerprintValid is ${isFingerprintValid}, isSignatureValid is ${isSignatureValid}`, 126 | ); 127 | } 128 | await oidc.interactionFinished(req, res, result, { mergeWithLastSubmission: false }); 129 | } catch (err) { 130 | result = { 131 | error: 'access_denied', 132 | error_description: err, 133 | }; 134 | console.error(err); 135 | 136 | next(err); 137 | } 138 | }); 139 | 140 | // resume auth flow after account selection 141 | router.post('/interaction/:uid/continue', setNoCache, body, async (req, res, next) => { 142 | try { 143 | const interaction = await oidc.interactionDetails(req, res); 144 | const { 145 | prompt: { name, details }, 146 | } = interaction; 147 | assert.equal(name, 'select_account'); 148 | 149 | if (req.body.switch) { 150 | if (interaction.params.prompt) { 151 | const prompts = new Set(interaction.params.prompt.split(' ')); 152 | prompts.add('login'); 153 | interaction.params.prompt = [...prompts].join(' '); 154 | } else { 155 | interaction.params.prompt = 'login'; 156 | } 157 | await interaction.save(); 158 | } 159 | 160 | const result = { select_account: {} }; 161 | await oidc.interactionFinished(req, res, result, { mergeWithLastSubmission: false }); 162 | } catch (err) { 163 | next(err); 164 | } 165 | }); 166 | 167 | // consent third-party screen 168 | router.post('/interaction/:uid/confirm', setNoCache, body, async (req, res, next) => { 169 | try { 170 | const { 171 | prompt: { name, details }, 172 | } = await oidc.interactionDetails(req, res); 173 | assert.strictEqual(name, 'consent'); 174 | 175 | const consent = { 176 | rejectedScopes: [], 177 | rejectedClaims: [], 178 | replace: false, 179 | }; 180 | const result = { consent }; 181 | // skip consent screen at the moment, the only client configured is a first-party 182 | await oidc.interactionFinished(req, res, result, { mergeWithLastSubmission: true }); 183 | } catch (err) { 184 | next(err); 185 | } 186 | }); 187 | 188 | // abort auth flow 189 | router.get('/interaction/:uid/abort', setNoCache, async (req, res, next) => { 190 | try { 191 | const result = { 192 | error: 'access_denied', 193 | error_description: 'End-User aborted interaction', 194 | }; 195 | await oidc.interactionFinished(req, res, result, { mergeWithLastSubmission: false }); 196 | } catch (err) { 197 | next(err); 198 | } 199 | }); 200 | 201 | router.use('/', oidc.callback); 202 | 203 | export default router; 204 | -------------------------------------------------------------------------------- /src/routes/validate.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { atob, getRecordsAsync } from '../hns'; 3 | const router = express.Router(); 4 | 5 | function setNoCache(req, res, next) { 6 | res.set('Pragma', 'no-cache'); 7 | res.set('Cache-Control', 'no-cache, no-store'); 8 | next(); 9 | } 10 | 11 | router.get('/', setNoCache, async (req, res, next) => { 12 | try { 13 | const id = atob(req.query.id); 14 | const deviceId = atob(req.query.deviceId); 15 | const fp = atob(req.query.fp); 16 | const fingerprintRecords = (await getRecordsAsync(`${deviceId}._auth.${id}`)).filter((r) => 17 | r.fingerprint ? true : false, 18 | ); 19 | const isFingerprintValid = 20 | fingerprintRecords.length > 0 && fingerprintRecords[0].fingerprint == fp; 21 | res.send({ success: isFingerprintValid }); 22 | } catch (e) { 23 | res.send({ success: false }); 24 | } 25 | }); 26 | 27 | export default router; 28 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { port } from './config'; 3 | import { setupPostMiddlewares, setupPreMiddlewares } from './startup'; 4 | 5 | const server = express(); 6 | setupPreMiddlewares(server); 7 | setupPostMiddlewares(server); 8 | server.listen(port, () => { 9 | console.log(`server listening port ${port}`); 10 | }); 11 | -------------------------------------------------------------------------------- /src/skip-policy.ts: -------------------------------------------------------------------------------- 1 | function skipPolicy(oidc) { 2 | if (process.env.NODE_ENV === 'development') { 3 | console.warn('implicit-force-https diabled'); 4 | console.warn('implicit-forbid-localhost disabled'); 5 | 6 | oidc.Client.Schema.prototype.invalidate = function invalidate(message, code) { 7 | if (code === 'implicit-force-https' || code === 'implicit-forbid-localhost') { 8 | return; 9 | } 10 | }; 11 | } 12 | } 13 | 14 | export { skipPolicy }; 15 | -------------------------------------------------------------------------------- /src/startup.ts: -------------------------------------------------------------------------------- 1 | import * as sapper from '@sapper/server'; 2 | import { RewriteFrames } from '@sentry/integrations'; 3 | import * as Sentry from '@sentry/node'; 4 | import compression from 'compression'; 5 | import redisConnect from 'connect-redis'; 6 | import cookieParser from 'cookie-parser'; 7 | import cors from 'cors'; 8 | import type { Request, Response } from 'express'; 9 | import express from 'express'; 10 | import rateLimit from 'express-rate-limit'; 11 | import session from 'express-session'; 12 | import helmet from 'helmet'; 13 | import logger from 'morgan'; 14 | import { config, sentry } from './config'; 15 | import { redis } from './redis-client'; 16 | // routes 17 | import oidcProviderRouter from './routes/oidc-provider'; 18 | import validateRouter from './routes/validate'; 19 | 20 | export type SapperSession = {}; 21 | 22 | const rootDir = __dirname ?? process.cwd(); 23 | 24 | export function setupPreMiddlewares(app) { 25 | app.get('/healthz', (req, res) => { 26 | res.send('ok'); 27 | }); 28 | app.use((req, res, next) => { 29 | if (process.env.NODE_ENV === 'production') { 30 | if (req.headers['x-forwarded-proto'] !== 'https') 31 | return res.redirect('https://' + req.headers.host + req.url); 32 | else return next(); 33 | } else { 34 | return next(); 35 | } 36 | }); 37 | Sentry.init({ 38 | dsn: sentry, 39 | integrations: [ 40 | new RewriteFrames({ 41 | root: rootDir, 42 | }), 43 | ], 44 | }); 45 | 46 | const RedisStore = redisConnect(session); 47 | const limiter = rateLimit({ 48 | windowMs: 15 * 60 * 1000, // 15 minutes 49 | max: 1000, // limit each IP to 100 requests per windowMs 50 | }); 51 | 52 | app.use(Sentry.Handlers.requestHandler()); 53 | app.set('trust proxy', true); 54 | app.use(helmet({ contentSecurityPolicy: false })); 55 | app.use(cors()); 56 | app.use(limiter); 57 | app.use( 58 | session({ 59 | store: new RedisStore({ client: redis.createClient({ keyPrefix: 'session:' }) }), 60 | secret: config.session_secret, 61 | resave: false, 62 | saveUninitialized: true, 63 | cookie: { secure: app.get('env') === 'production' }, 64 | }), 65 | ); 66 | app.use(logger('dev')); 67 | app.use(cookieParser()); 68 | app.use(compression({ threshold: 0 })); 69 | 70 | app.use(compression({ threshold: 0 })); 71 | app.use(express.static('static')); 72 | app.use('/validate', validateRouter); 73 | app.use('/oidc', oidcProviderRouter); 74 | app.use( 75 | sapper.middleware({ 76 | session: async (req: Request, _: Response) => {}, 77 | }), 78 | ); 79 | 80 | return app; 81 | } 82 | 83 | export function setupPostMiddlewares(app) { 84 | app.use(Sentry.Handlers.errorHandler()); 85 | 86 | function notFound(req: Request, res: Response, next: (error: Error) => void) { 87 | res.status(404); 88 | const error = new Error(`🔍 - Not Found - ${req.originalUrl}`); 89 | next(error); 90 | } 91 | 92 | function errorHandler(error: Error, _req: Request, res: Response, _next: () => void) { 93 | const statusCode = res.statusCode !== 200 ? res.statusCode : 500; 94 | res.status(statusCode); 95 | res.json({ 96 | message: error.message, 97 | stack: process.env.NODE_ENV === 'production' ? '🥞' : error.stack, 98 | }); 99 | } 100 | 101 | app.use(notFound, errorHandler); 102 | 103 | return app; 104 | } 105 | -------------------------------------------------------------------------------- /src/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | %sapper.base% 13 | 14 | 15 | 16 | 17 | 18 | 25 | 26 | %sapper.scripts% %sapper.styles% %sapper.head% 27 | 28 | 29 |
%sapper.html%
30 | 31 | 32 | -------------------------------------------------------------------------------- /static/RobotoMono-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/namebasehq/handshake-oidc/7ae091ce697fdcfcee8c6ef2f4693aa3d8bfe900/static/RobotoMono-regular.woff -------------------------------------------------------------------------------- /static/RobotoVariable-subset.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/namebasehq/handshake-oidc/7ae091ce697fdcfcee8c6ef2f4693aa3d8bfe900/static/RobotoVariable-subset.woff2 -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/namebasehq/handshake-oidc/7ae091ce697fdcfcee8c6ef2f4693aa3d8bfe900/static/favicon.png -------------------------------------------------------------------------------- /static/global.css: -------------------------------------------------------------------------------- 1 | /*** CSS reset & common sense styles ***/ 2 | 3 | @font-face { 4 | font-family: 'Roboto Variable'; 5 | src: url('/RobotoVariable-subset.woff2') format('woff2'); 6 | font-weight: 1 999; 7 | font-display: swap; 8 | } 9 | 10 | @font-face { 11 | font-family: 'Roboto Mono'; 12 | src: url('/RobotoMono-regular.woff') format('woff'); 13 | font-weight: 400; 14 | font-display: swap; 15 | } 16 | 17 | html { 18 | display: flex; 19 | min-height: 100vh; 20 | flex-direction: column; 21 | } 22 | 23 | body, #sapper { 24 | flex: 1; 25 | height: 100%; 26 | } 27 | 28 | body, html { 29 | margin: 0; 30 | padding: 0; 31 | font-family: 'Roboto Variable', Roboto, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 32 | background-color: #141414; 33 | } 34 | 35 | * { 36 | box-sizing: border-box; 37 | transition: background-color 400ms ease, opacity 400ms ease, color 250ms ease, stroke 200ms ease-out, fill 200ms ease-out, transform 200ms ease-in-out; 38 | } 39 | 40 | *:focus-visible, *:focus { 41 | outline: #4891FF solid 1.5px; 42 | } 43 | 44 | *::before, *::after { 45 | box-sizing: border-box; 46 | } 47 | 48 | p, h1, h2, h3, h4, h5, h6 { 49 | margin: 0; 50 | padding: 0; 51 | } 52 | 53 | button { 54 | margin: 0; 55 | border: none; 56 | cursor: pointer; 57 | padding: 0; 58 | background: none; 59 | } 60 | 61 | button:disabled { 62 | cursor: not-allowed; 63 | opacity: 0.4; 64 | } 65 | 66 | a { 67 | color: inherit; 68 | cursor: pointer; 69 | text-decoration: none; 70 | } 71 | 72 | input::-moz-focus-inner { 73 | border: 0; 74 | margin: 0; 75 | padding: 0; 76 | } 77 | 78 | ul, ol, dd, dl { 79 | margin: 0; 80 | padding: 0; 81 | list-style: none; 82 | } 83 | 84 | @media (prefers-reduced-motion: reduce) { 85 | * { 86 | transition: none; 87 | } 88 | } 89 | 90 | .text-variant-huge { 91 | font-size: 24px; 92 | line-height: 34px; 93 | } 94 | 95 | .text-variant-regular { 96 | font-size: 16px; 97 | line-height: 24px; 98 | } 99 | 100 | .text-variant-small { 101 | font-size: 14px; 102 | line-height: 24px; 103 | } 104 | 105 | .text-variant-tiny { 106 | font-size: 12px; 107 | line-height: 16px; 108 | } 109 | 110 | .text-weight-regular { 111 | font-weight: 400; 112 | font-variation-settings: 'wght' 400; 113 | } 114 | 115 | .text-weight-medium { 116 | font-weight: 500; 117 | font-variation-settings: 'wght' 500; 118 | } 119 | 120 | .text-roboto-variable { 121 | font-family: 'Roboto Variable'; 122 | } 123 | 124 | .text-roboto-mono { 125 | font-family: 'Roboto Mono'; 126 | } 127 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["DOM", "webworker", "ES2018", "ESNext"], 5 | "target": "es2017", 6 | "allowSyntheticDefaultImports": true, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "importsNotUsedAsValues": "error", 10 | "isolatedModules": true, 11 | "moduleResolution": "node", 12 | "sourceMap": true, 13 | "strict": false, 14 | "types": ["svelte", "node"] 15 | }, 16 | "include": [ 17 | "src/*", 18 | "src/*.ts", 19 | "src/**/*", 20 | "src/**/*.ts", 21 | "src/node_modules", 22 | "src/node_modules/**/*", 23 | ".eslintrc.js", 24 | "rollup.config.js", 25 | "svelte.config.js" 26 | ], 27 | "exclude": ["node_modules/*", "__sapper__/*", "public/*", "static/*"] 28 | } 29 | --------------------------------------------------------------------------------