diff --git a/config/config_schema.json b/config/config_schema.json index b48194c..20a31c8 100644 --- a/config/config_schema.json +++ b/config/config_schema.json @@ -341,6 +341,143 @@ ] } } + }, + "oidcAuthProvider": { + "title": "OpenID Connect AuthProvider", + "description": "OpenID Connect authentication configuration", + "type": "object", + "additionalProperties": false, + "required": [ + "idpUrl", + "uniqueField", + "clientId", + "scope", + "localPublicKeyLocation", + "localPrivateKeyLocation", + "keyAlgorithm", + "issuer", + "symmetricKeyLocation" + ], + "properties": { + "idpUrl": { + "description": "Base URL for identity provider endpoint", + "type": "string", + "format": "uri", + "pattern": "^https?://", + "examples": [ + "https://domain.xyz/auth/realms/example" + ] + }, + "uniqueField": { + "description": "Name of unique field to use as user ID. Note that as per the OpenID Connect specification only sub/issuer combination is guaranteed to be stable and unique for an arbitrary issuer, though other values such as preferred_username may be usable when the team running the CARTA installation and the issuer are the same.", + "type": "string", + "examples": [ + "sub", + "preferred_username" + ], + "default": "sub" + }, + "clientId": { + "description": "Client ID as registered with identity provider", + "type": "string", + "minLength": 1, + "examples": [ + "carta" + ] + }, + "clientSecret": { + "description": "Client secret as registered with identity provider", + "type": "string", + "minLength": 1 + }, + "scope": { + "description": "Scopes to request from the OpenID Connect server", + "type": "string", + "default": "openid", + "examples": [ + "openid", + "openid groups" + ] + }, + "userLookupTable": { + "description": "Path of user lookup table as text file in format . If no user lookup is needed, this should be omitted. Example table given in `usertable.txt.stub`", + "type": "string", + "examples": [ + "/etc/carta/userlookup.txt" + ] + }, + "groupsField": { + "description": "Name of field containing list of user roles/groups", + "type": "string", + "examples": [ + "groups", + "roles" + ] + }, + "requiredGroup": { + "description": "Role to ensure is included among the values in groupsField", + "type": "string", + "examples": [ + "carta-users", + "carta-testers" + ] + }, + "localPublicKeyLocation": { + "description": "Path to public key (in PEM format) used for verifying JWTs", + "type": "string", + "examples": [ + "/etc/carta/carta_public.pem" + ] + }, + "localPrivateKeyLocation": { + "description": "Path to private key (in PEM format) used for signing JWTs", + "type": "string", + "examples": [ + "/etc/carta/carta_private.pem" + ] + }, + "keyAlgorithm": { + "$ref": "#/definitions/keyAlgorithm", + "default": "RS256" + }, + "issuer": { + "description": "Issuer field for JWT", + "type": "string", + "examples": [ + "my-carta-server" + ] + }, + "cacheAccessTokenMinValidity": { + "description": "If an access token was previously issued from the upstream server with at least this many seconds of lifetime remaining, a new upstream query will not be performed and a local token with the previous token's remaining lifetime will be issued instead", + "type": "integer", + "default": 100 + }, + "symmetricKeyLocation": { + "description": "Path to symmetric key (base64-encoded) used for refresh tokens. At present this uses the A256GCM algorithm which requires 32 bytes of random data which can be generated using `openssl rand -base64 32`", + "type": "string", + "examples": [ + "/etc/carta/carta_symmetric.pem" + ] + }, + "symmetricKeyType": { + "description": "Selected from the 'JSON Web Signature and Encryption Algorithms' section of https://www.iana.org/assignments/jose/jose.xhtml", + "type": "string", + "default": "A256GCM" + }, + "additionalAuthParams": { + "description": "Additional parameters to include in authentication requests to deal with identity providers. The example contains the additional arguments required to ensure that Google provide a refresh token when using it with OIDC.", + "type": "array", + "default": [], + "examples": [ + [[["access_type", "offline"], ["prompt", "consent"]]] + ], + "items": { + "type": "array", + "minItems": 2, + "maxItems": 2 + } + } + } } }, "additionalProperties": false, @@ -369,6 +506,10 @@ "external": { "description": "External AuthProvider", "$ref": "#/definitions/externalAuthProvider" + }, + "oidc": { + "description": "OpenID Connect AuthProvider", + "$ref": "#/definitions/oidcAuthProvider" } }, "default": { diff --git a/docs/src/configuration.rst b/docs/src/configuration.rst index 6b37052..7bdc8d8 100644 --- a/docs/src/configuration.rst +++ b/docs/src/configuration.rst @@ -35,7 +35,7 @@ To provide the ``carta`` user with these privileges, you must make modifications Authentication ~~~~~~~~~~~~~~ -When configured to use PAM or LDAP authentication, the controller signs and validates refresh and access tokens with SSL keys. You can generate a private/public key pair in PEM format using ``openssl``: +The controller signs and validates tokens with SSL keys. You can generate a private/public key pair in PEM format using ``openssl``: .. code-block:: shell @@ -43,6 +43,12 @@ When configured to use PAM or LDAP authentication, the controller signs and vali openssl genrsa -out carta_private.pem 4096 openssl rsa -in carta_private.pem -outform PEM -pubout -out carta_public.pem +A public/private keypair is used to authenticate access tokens. OIDC authentication requires an additional symmetric encryption key for refresh tokens. LDAP or PAM authentication uses the same public/private keypair both for access tokens and for refresh tokens. If you use the default encryption algorithm, you can again use `openssl` to generate the needed key: + +.. code-block:: shell + + openssl rand -base64 32 > /etc/carta/symm.key + PAM may be configured to use the host's local UNIX user authentication, or to communicate with a local or remote LDAP server. If the UNIX module is used for authentication, the ``carta`` user must be given read-only access to ``/etc/shadow``. This is not required if you use PAM's LDAP module or the direct LDAP authentication method. .. _config-nginx: diff --git a/package-lock.json b/package-lock.json index d3a9f7f..809832f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,17 +12,20 @@ "@pm2/io": "^5.0.0", "ajv": "^8.2.0", "ajv-formats": "^2.1.0", + "axios": "^0.27.2", "body-parser": "^1.19.0", "carta-frontend": "3.0.1", "chalk": "^4.1.2", "compression": "^1.7.4", "cookie-parser": "^1.4.5", "cors": "^2.8.5", + "crypto": "^1.0.1", "express": "^4.17.1", "express-bearer-token": "^2.4.0", "google-auth-library": "^8.7.0", "hjson": "^3.2.2", "http-proxy": "^1.18.1", + "jose": "^4.8.3", "jsonc-parser": "^3.0.0", "jsonwebtoken": "^9.0.0", "ldapauth-fork": "^5.0.1", @@ -676,6 +679,20 @@ "semver": "bin/semver" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", + "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", + "dependencies": { + "follow-redirects": "^1.14.9", + "form-data": "^4.0.0" + } + }, "node_modules/babel-walk": { "version": "3.0.0-canary-5", "resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz", @@ -946,6 +963,17 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -1109,6 +1137,12 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "node_modules/crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==", + "deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in." + }, "node_modules/d": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", @@ -1144,6 +1178,14 @@ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", @@ -1480,6 +1522,19 @@ } } }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1899,6 +1954,14 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" }, + "node_modules/jose": { + "version": "4.9.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.9.3.tgz", + "integrity": "sha512-f8E/z+T3Q0kA9txzH2DKvH/ds2uggcw0m3vVPSB9HrSkrQ7mojjifvS7aR8cw+lQl2Fcmx9npwaHpM/M3GD8UQ==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-stringify": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz", @@ -4027,6 +4090,20 @@ } } }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "axios": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", + "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", + "requires": { + "follow-redirects": "^1.14.9", + "form-data": "^4.0.0" + } + }, "babel-walk": { "version": "3.0.0-canary-5", "resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz", @@ -4231,6 +4308,14 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, "compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -4360,6 +4445,11 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==" + }, "d": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", @@ -4389,6 +4479,11 @@ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, "delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", @@ -4658,6 +4753,16 @@ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==" }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, "forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -4971,6 +5076,11 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" }, + "jose": { + "version": "4.9.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.9.3.tgz", + "integrity": "sha512-f8E/z+T3Q0kA9txzH2DKvH/ds2uggcw0m3vVPSB9HrSkrQ7mojjifvS7aR8cw+lQl2Fcmx9npwaHpM/M3GD8UQ==" + }, "js-stringify": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz", diff --git a/package.json b/package.json index ac5e858..67d9c8b 100644 --- a/package.json +++ b/package.json @@ -22,17 +22,20 @@ "@pm2/io": "^5.0.0", "ajv": "^8.2.0", "ajv-formats": "^2.1.0", + "axios": "^0.27.2", "body-parser": "^1.19.0", "carta-frontend": "3.0.1", "chalk": "^4.1.2", "compression": "^1.7.4", "cookie-parser": "^1.4.5", "cors": "^2.8.5", + "crypto": "^1.0.1", "express": "^4.17.1", "express-bearer-token": "^2.4.0", "google-auth-library": "^8.7.0", "hjson": "^3.2.2", "http-proxy": "^1.18.1", + "jose": "^4.8.3", "jsonc-parser": "^3.0.0", "jsonwebtoken": "^9.0.0", "ldapauth-fork": "^5.0.1", diff --git a/public/dashboard.js b/public/dashboard.js index 52c383a..8e6f4a2 100644 --- a/public/dashboard.js +++ b/public/dashboard.js @@ -40,7 +40,7 @@ apiCall = async (callName, jsonBody, method, authRequired) => { // If access token expires in under 10 seconds, attempt to refresh before making the call if (authRequired && tokenExpiryTime < currentTime + 10) { try { - if (authenticationType === "local") { + if (authenticationType === "local" || authenticationType === "oidc") { await refreshLocalToken(); } else if (authenticationType === "google") { await refreshGoogleToken(); @@ -196,6 +196,8 @@ handleLogout = async () => { clearInterval(serverCheckHandle); if (authenticationType === "google") { await handleGoogleLogout(); + } else if (authenticationType === "oidc") { + window.open(`${apiBase}/auth/logout`, "_self"); } else { await handleLocalLogout(); } @@ -357,19 +359,30 @@ window.onload = async () => { }] }); + // Check for completed OIDC login + const usp = new URLSearchParams(window.location.search); + if (usp.has("oidcuser")) { + console.log("Completed OIDC login"); + await refreshLocalToken(); + onLoginSucceeded(usp.get("oidcuser"), "oidc") + } else if (usp.has("err")) { + console.log(usp.get("err")); + notyf.open({type: "error", message: usp.get("err")}); + } + // Hide open button if using popup if (isPopup) { document.getElementById("open").style.display = "none"; } const existingLoginType = localStorage.getItem("authenticationType"); - if (existingLoginType === "local") { + if (existingLoginType === "local" || (existingLoginType === "oidc" && !usp.has("oidcuser"))) { try { const res = await apiCall("auth/refresh", {}, "post"); if (res.ok) { const body = await res.json(); if (body.access_token) { setToken(body.access_token, body.expires_in || Number.MAX_VALUE); - await onLoginSucceeded(body.username, "local"); + await onLoginSucceeded(body.username, existingLoginType); } else { await handleLogout(); } @@ -395,6 +408,11 @@ window.onload = async () => { passwordInput.onkeyup = handleKeyup; } + const oidcLoginButton = document.getElementById("oidcLogin"); + if (oidcLoginButton) { + oidcLoginButton.onclick = () => { window.location.href = `${apiBase}/auth/login` }; + } + document.getElementById("stop").onclick = handleServerStop; document.getElementById("open").onclick = handleOpenCarta; document.getElementById("show-logs").onclick = handleLog; diff --git a/src/auth/index.ts b/src/auth/index.ts index 914c14e..f322f1d 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -5,6 +5,7 @@ import {RequestHandler, AsyncRequestHandler, AuthenticatedRequest, Verifier, Use import {ServerConfig, RuntimeConfig} from "../config"; import {generateExternalVerifiers, watchUserTable} from "./external"; import {generateLocalRefreshHandler, generateLocalVerifier} from "./local"; +import {generateLocalOidcRefreshHandler, generateLocalOidcVerifier, oidcCallbackHandler, oidcLogoutHandler, oidcLoginStart, initOidc} from "./oidc"; import {getLdapLoginHandler} from "./ldap"; import {getPamLoginHandler} from "./pam"; import {generateGoogleVerifier, validGoogleIssuers} from "./google"; @@ -22,6 +23,10 @@ let refreshHandler: AsyncRequestHandler = (req, res) => { throw {statusCode: 501, message: "Token refresh not implemented"}; }; +let callbackHandler: AsyncRequestHandler = (req, res) => { + throw {statusCode: 501, message: "Token refresh not implemented"}; +}; + // Local providers if (ServerConfig.authProviders.pam) { const authConf = ServerConfig.authProviders.pam; @@ -46,6 +51,17 @@ if (ServerConfig.authProviders.pam) { if (tablePath) { watchUserTable(userMaps, authConf.issuers, tablePath); } +} else if (ServerConfig.authProviders.oidc) { + const authConf = ServerConfig.authProviders.oidc; + generateLocalOidcVerifier(tokenVerifiers, authConf); + refreshHandler = generateLocalOidcRefreshHandler(authConf); + loginHandler = (req, res) => oidcLoginStart(req, res, authConf); + callbackHandler = (req, res) => oidcCallbackHandler(req, res, authConf); + initOidc(authConf); + if (authConf.userLookupTable) { + console.log(`Using ${authConf.userLookupTable} for user mapping`); + watchUserTable(userMaps, authConf.issuer, authConf.userLookupTable); + } } // Check for empty token verifies @@ -80,6 +96,7 @@ export async function authGuard(req: AuthenticatedRequest, res: express.Response if (tokenString) { try { const token = await verifyToken(tokenString); + if (!token || !token.username) { next({statusCode: 403, message: "Not authorized"}); } else { @@ -116,7 +133,14 @@ function handleCheckAuth(req: AuthenticatedRequest, res: express.Response) { } export const authRouter = express.Router(); -authRouter.post("/login", noCache, loginHandler); -authRouter.post("/logout", noCache, logoutHandler); +if (ServerConfig.authProviders.oidc) { + authRouter.get("/logout", noCache, oidcLogoutHandler); + authRouter.get("/oidcCallback", noCache, callbackHandler); + authRouter.get("/login", noCache, loginHandler); +} +else { + authRouter.post("/login", noCache, loginHandler); + authRouter.post("/logout", noCache, logoutHandler); +} authRouter.post("/refresh", noCache, refreshHandler); authRouter.get("/status", authGuard, noCache, handleCheckAuth); diff --git a/src/auth/oidc.ts b/src/auth/oidc.ts new file mode 100644 index 0000000..56b9db6 --- /dev/null +++ b/src/auth/oidc.ts @@ -0,0 +1,373 @@ +import axios from "axios"; +import * as express from "express"; +import * as fs from "fs"; +import * as jose from 'jose'; +import type { GetKeyFunction } from "jose/dist/types/types" + +import {CartaOidcAuthConfig} from "../types"; +import {RuntimeConfig, ServerConfig} from "../config"; +import {Verifier} from "../types"; +import { createHash, createPrivateKey, createPublicKey, createSecretKey, KeyObject, randomBytes } from "crypto"; +import { ceil, floor } from "lodash"; +import {initRefreshManager, acquireRefreshLock, releaseRefreshLock, getAccessTokenExpiry, clearTokens, setAccessTokenExpiry, setRefreshToken, getRefreshToken} from "./oidcRefreshManager"; + +let privateKey: KeyObject; +let publicKey: KeyObject; +let symmetricKey: KeyObject; +let jwksManager: GetKeyFunction; + +let oidcAuthEndpoint: string; +let oidcIssuer: string; +let oidcLogoutEndpoint: string; +let oidcTokenEndpoint: string; + +export async function initOidc(authConf: CartaOidcAuthConfig) { + // Load public & private keys + publicKey = createPublicKey(fs.readFileSync(authConf.localPublicKeyLocation)); + privateKey = createPrivateKey(fs.readFileSync(authConf.localPrivateKeyLocation)); + symmetricKey = createSecretKey(Buffer.from(fs.readFileSync(authConf.symmetricKeyLocation, 'utf-8'), 'base64')); + + // Parse details of IdP from metadata URL + const idpConfig = await axios.get(authConf.idpUrl + "/.well-known/openid-configuration"); + oidcAuthEndpoint = idpConfig.data['authorization_endpoint']; + oidcIssuer = idpConfig.data['issuer']; + oidcLogoutEndpoint = idpConfig.data['end_session_endpoint']; + oidcTokenEndpoint = idpConfig.data['token_endpoint']; + + // Init JWKS key management + console.log(`Setting up JWKS management for ${idpConfig.data['jwks_uri']}`); + jwksManager = jose.createRemoteJWKSet(new URL(idpConfig.data['jwks_uri'])); + + // Init refresh token management + await initRefreshManager(); +} + +function returnErrorMsg (req: express.Request, res: express.Response, statusCode: number, msg: string) { + if (req.header('accept') == 'application/json') { + return res.status(statusCode).json({ statusCode: statusCode, message: msg }) + } + else { + // Errors are presented to the user on the dashboard rather than returned via JSON messages + return res.redirect( + `${new URL(`${RuntimeConfig.dashboardAddress}`, ServerConfig.serverAddress).href}?${new URLSearchParams({'err':msg}).toString()}` + ); + } +} + +// A helper function as initial call to the IdP token endpoint and renewals are mostly the same +async function callIdpTokenEndpoint (usp: URLSearchParams, req: express.Request, res: express.Response, + authConf: CartaOidcAuthConfig, scriptingToken: boolean = false, + isLogin: boolean = false, sessionId: string, sessionEncKey: Buffer | undefined) { + + // Fill in the common request elements + usp.set("client_id", authConf.clientId); + usp.set("client_secret", authConf.clientSecret); + usp.set("scope", authConf.scope); + + try { + const result = await axios.post(`${oidcTokenEndpoint}`, usp); + if (result.status != 200) { + return returnErrorMsg(req, res, 500, "Authentication error"); + } + + const { payload, protectedHeader } = await jose.jwtVerify(result.data['id_token'], jwksManager, { + issuer: oidcIssuer, + }); + + // Check audience + if (payload.aud != authConf.clientId) { + return returnErrorMsg(req, res, 500, "Service received an ID token directed to a different service"); + } + + // Create / retrieve session encryption key + if (sessionEncKey === undefined) { + //console.log("No session key received. Assuming initial login") + sessionEncKey = randomBytes(32); + } + + let username = payload[authConf.uniqueField]; + if (username === undefined) { + return returnErrorMsg(req, res, 500, "Unable to match to a local user"); + } + + // Update DB to reflect new token + associated access token expiry + if (result.data['refresh_token'] !== undefined) { + setRefreshToken(username, sessionId, result.data['refresh_token'], + sessionEncKey, parseInt(result.data['refresh_expires_in'])); + } + + const refreshExpiry = result.data['refresh_expires_in'] !== undefined ? result.data['refresh_expires_in'] : result.data['expires_in']; + //refreshData['access_token_expiry'] = floor(new Date().getTime() / 1000) + result.data['expires_in']; + if (result.data['expires_in'] !== undefined) { + setAccessTokenExpiry(username, sessionId, parseInt(result.data['expires_in'])); + //console.log(`Access token expires in:\t${result.data['expires_in']}`) + } + + // Check group membership + if (authConf.requiredGroup !== undefined) { + if (payload[`${authConf.groupsField}`] === undefined) { + return returnErrorMsg(req, res, 403, "Identity provider did not supply group membership"); + } + const idpGroups = payload[`${authConf.groupsField}`]; + if (Array.isArray(idpGroups)) { + const groupList: string[] = idpGroups; + if (!groupList.includes(`${authConf.requiredGroup}`)) { + return returnErrorMsg(req, res, 403, "Not part of required group"); + } + } else { + return returnErrorMsg(req, res, 403, "Invalid group membership info received"); + } + } + + // Build refresh token + // If there's no actual refresh token then this will only last for as long as the access token does + const refreshData = { + username, + sessionId, + sessionEncKey: sessionEncKey.toString('hex') + }; + //console.log(`Session key in refresh token:\t${refreshData['sessionEncKey']}`) + const rt = await new jose.EncryptJWT(refreshData) + .setProtectedHeader({ alg: 'dir', enc: authConf.symmetricKeyType }) + .setIssuedAt() + .setIssuer(authConf.issuer) + .setExpirationTime(`${refreshExpiry}s`) + .encrypt(symmetricKey); + res.cookie("Refresh-Token", rt, { + path: RuntimeConfig.authPath, + maxAge: parseInt(refreshExpiry) * 1000, + httpOnly: true, + secure: !ServerConfig.httpOnly, + sameSite: "strict" + }); + + if (result.data['id_token'] !== undefined) { + res.cookie("Logout-Token", result.data['id_token'], { + path: RuntimeConfig.logoutAddress, + httpOnly: true, + secure: !ServerConfig.httpOnly, + sameSite: "strict" + }); + } + + // After login redirect to the dashboard, but otherwise return a bearer token + if (isLogin) { + return res.redirect(`${new URL(`${RuntimeConfig.dashboardAddress}`, ServerConfig.serverAddress).href}?${new URLSearchParams(`oidcuser=${username}`).toString()}`); + } + else { + let newAccessToken = { username }; + if (scriptingToken) + newAccessToken['scripting'] = true; + const newAccessTokenJWT = await new jose.SignJWT(newAccessToken) + .setProtectedHeader({ alg: authConf.keyAlgorithm }) + .setIssuedAt() + .setIssuer(authConf.issuer) + .setExpirationTime(`${result.data['expires_in']}s`) + .sign(privateKey); + return res.json({ + access_token: newAccessTokenJWT, + token_type: "bearer", + username: payload.username, + expires_in: result.data['expires_in'] + }); + } + + } catch(err) { + console.warn(err); + return returnErrorMsg(req, res, 500, "Error requesting tokens from identity provider"); + } +} + +export function generateLocalOidcRefreshHandler (authConf: CartaOidcAuthConfig) { + return async (req: express.Request, res: express.Response) => { + //console.debug("Running OIDC refresh handler") + const refreshTokenCookie = req.cookies["Refresh-Token"]; + const scriptingToken = req.body?.scripting === true; + + if (refreshTokenCookie) { + try { + // Verify that the token is legit + const { payload, protectedHeader } = await jose.jwtDecrypt(refreshTokenCookie, symmetricKey, { + issuer: authConf.issuer + }); + + try { + if (! await acquireRefreshLock(payload?.sessionId,10)) { + return returnErrorMsg(req, res, 500, "Timed out waiting to acquire lock"); + } + } catch (err) { + return returnErrorMsg(req, res, 500, "Locking error"); + } + + try { + // Check if access token validity is there and at least cacheAccessTokenMinValidity seconds from expiry + const remainingValidity = await getAccessTokenExpiry(payload.username, payload.sessionId); + if (remainingValidity > authConf.cacheAccessTokenMinValidity) { + let newAccessToken = { + username: payload.username, + expires_in: remainingValidity + }; + if (scriptingToken) + newAccessToken['scripting'] = true; + const newAccessTokenJWT = await new jose.SignJWT(newAccessToken) + .setProtectedHeader({ alg: authConf.keyAlgorithm }) + .setIssuedAt() + .setIssuer(`${ServerConfig.authProviders.oidc?.issuer}`) + .setExpirationTime(`${remainingValidity}s`) + .sign(privateKey); + + return res.json({ + access_token: newAccessTokenJWT, + token_type: "bearer", + username: payload.username, + expires_in: remainingValidity + }); + } else { + // Need to request a new token from upstream + const usp = new URLSearchParams(); + const sessionEncKey = Buffer.from(`${payload?.sessionEncKey}`, 'hex'); + usp.set("grant_type", "refresh_token"); + usp.set("refresh_token", `${await getRefreshToken(payload.username, payload.sessionId, sessionEncKey)}`); + return await callIdpTokenEndpoint(usp, req, res, authConf, scriptingToken, false, `${payload['sessionId']}`, sessionEncKey); + } + } finally { + await releaseRefreshLock(payload?.sessionId); + } + } catch (err) { + return returnErrorMsg(req, res, 400, "Invalid refresh token"); + } + } else { + return returnErrorMsg(req, res, 400, "Missing refresh token"); + } + } +} + +export function generateLocalOidcVerifier (verifierMap: Map, authConf: CartaOidcAuthConfig) { + // Note that we need only verify the tokens we've wrapped ourselves here + verifierMap.set(authConf.issuer, async cookieString => { + const result = await jose.jwtVerify(cookieString, privateKey, { + issuer: authConf.issuer, + algorithms: [authConf.keyAlgorithm] + }); + return result.payload; + }); +} + +export async function oidcLoginStart (req: express.Request, res: express.Response, authConf: CartaOidcAuthConfig) { + try { + const usp = new URLSearchParams(); + + // Generate PKCE verifier & challenge + const urlSafeChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~"; + const codeVerifier = Array.from({length:64}, (_,i) => urlSafeChars[Math.floor(Math.random() * urlSafeChars.length)]).join(""); + const encryptedCodeVerifier = await new jose.CompactEncrypt(new TextEncoder().encode(codeVerifier)) + .setProtectedHeader({ alg: 'RSA-OAEP', enc: 'A128GCM' }) + .encrypt(publicKey); + + res.cookie('oidcVerifier', encryptedCodeVerifier, { + maxAge: 600000, + httpOnly: true, + secure: !ServerConfig.httpOnly, + }); + const codeChallenge = createHash('sha256') + .update(codeVerifier, 'utf-8') + .digest('base64url') + usp.set('code_challenge_method', 'S256'); + usp.set('code_challenge', codeChallenge); + + // Create session key + const sessionId = Array.from({length:32}, (_,i) => urlSafeChars[Math.floor(Math.random() * urlSafeChars.length)]).join(""); + res.cookie('sessionId', sessionId, { + path: (new URL(RuntimeConfig.apiAddress + '/auth/oidcCallback', ServerConfig.serverAddress)).href, + maxAge: 600000, + httpOnly: true, + secure: !ServerConfig.httpOnly, + }); + usp.set('state', sessionId); + + usp.set('client_id', authConf.clientId); + usp.set('redirect_uri', (new URL(RuntimeConfig.apiAddress + '/auth/oidcCallback', ServerConfig.serverAddress)).href); + usp.set('response_type', 'code'); + usp.set('scope', authConf.scope); + + // Allow arbitrary params to be passed for IdPs like Google that require additional ones + for (const item of authConf.additionalAuthParams) { + usp.set(item[0],item[1]) + } + + // Return redirect + return res.redirect(`${oidcAuthEndpoint}?${usp.toString()}`); + } catch (err) { + console.log(err); + return returnErrorMsg(req, res, 500, err); + } +} + +export async function oidcCallbackHandler(req: express.Request, res: express.Response, authConf: CartaOidcAuthConfig) { + try { + //console.debug("Running OIDC callback handler"); + const usp = new URLSearchParams(); + + if (req.cookies['oidcVerifier'] === undefined) { + return returnErrorMsg(req, res, 400, "Missing OIDC verifier"); + } + if (req.cookies['sessionId'] === undefined) { + return returnErrorMsg(req, res, 400, "Missing session ID"); + } else if (req.cookies['sessionId'] != `${req.query.state}`) { + return returnErrorMsg(req, res, 400, "Invalid session ID"); + } else { + res.clearCookie('sessionId'); + } + + const decryptedCodeVerifier = await jose.compactDecrypt(req.cookies['oidcVerifier'], privateKey); + const codeVerifier = new TextDecoder().decode(decryptedCodeVerifier.plaintext); + + usp.set('code_verifier', codeVerifier); + res.clearCookie("oidcVerifier"); + usp.set("code", `${req.query.code}`); + usp.set("grant_type", "authorization_code"); + usp.set('redirect_uri', (new URL(RuntimeConfig.apiAddress + '/auth/oidcCallback', ServerConfig.serverAddress)).href); + + return await callIdpTokenEndpoint (usp, req, res, authConf, false, true, `${req.query.state}`, undefined); + } catch (err) { + console.log(err); + return returnErrorMsg(req, res, 500, err); + } +} + +export async function oidcLogoutHandler(req: express.Request, res: express.Response) { + try { + res.cookie("Refresh-Token", "", { + path: RuntimeConfig.authPath, + maxAge: 0, + httpOnly: true, + secure: !ServerConfig.httpOnly, + sameSite: "strict" + }); + + if (oidcLogoutEndpoint !== undefined) { + // Redirect to the IdP to perform the logout + let usp = new URLSearchParams(); + if (req.cookies['Logout-Token'] !== undefined) { + usp.set('id_token_hint', req.cookies['Logout-Token']) + } + usp.set('post_logout_redirect_uri', `${ServerConfig.serverAddress}`); + + res.cookie("Logout-Token", "", { + path: RuntimeConfig.logoutAddress, + maxAge: 0, + httpOnly: true, + secure: !ServerConfig.httpOnly, + sameSite: "strict" + }); + + return res.redirect(`${oidcLogoutEndpoint}?${usp.toString()}`); + + } else { + return res.redirect(`${ServerConfig.serverAddress}`); + } + } catch (err) { + console.log(err); + return returnErrorMsg(req, res, 500, err); + } +} diff --git a/src/auth/oidcRefreshManager.ts b/src/auth/oidcRefreshManager.ts new file mode 100644 index 0000000..f8ab1cc --- /dev/null +++ b/src/auth/oidcRefreshManager.ts @@ -0,0 +1,211 @@ +import {Binary, Collection, MongoClient} from "mongodb"; +import { ceil, floor } from "lodash"; +import { createCipheriv, createDecipheriv, randomBytes } from "crypto"; + +import {ServerConfig} from "../config"; +import {verboseError} from "../util"; + +let lockCollection: Collection; +let refreshTokenCollection: Collection; +let accessTokenLifeTimesCollection: Collection; + +export async function initRefreshManager() { + try { + // A weird error occurs when a second DB object is created using same client + // so recreating the client here as well + const client = await MongoClient.connect(ServerConfig.database.uri); + const db = client.db(ServerConfig.database.databaseName); + + // Ensure that locks and refresh tokens tables are there with appropriate indices + if (! await db.listCollections({name: "tokenLock"}, {nameOnly: true}).hasNext()) { + console.log("Creating token lock collection") + lockCollection = await db.createCollection("tokenLock"); + } else { + lockCollection = await db.collection("tokenLock"); + } + if (! await db.listCollections({name: "refreshTokens"}, {nameOnly: true}).hasNext()) { + console.log("Creating refresh tokens collection") + refreshTokenCollection = await db.createCollection("refreshTokens"); + } else { + refreshTokenCollection = await db.collection("refreshTokens") + } + if (! await db.listCollections({name: "accessTokenLifetimes"}, {nameOnly: true}).hasNext()) { + console.log("Creating access token's lifetimes collection") + accessTokenLifeTimesCollection = await db.createCollection("accessTokenLifetimes"); + } else { + accessTokenLifeTimesCollection = await db.collection("accessTokenLifetimes"); + } + + // Create indices + const hasLockSessionIndex = await lockCollection.indexExists("lockSession"); + if (!hasLockSessionIndex) { + await lockCollection.createIndex({sessionid: 1}, {name: "lockSession", unique: true}); + console.log("Created session index for lockSession collection"); + } + const hasLockExpiryIndex = await lockCollection.indexExists("lockExpiry"); + if (!hasLockExpiryIndex) { + await lockCollection.createIndex({ "expireAt": 1 }, {name: "lockExpiry", expireAfterSeconds: 0}); + console.log("Created expiry index for lockSession collection"); + } + for (let coll of [refreshTokenCollection, accessTokenLifeTimesCollection]) { + const hasUserSessionIndex = await coll.indexExists("userSession"); + if (!hasUserSessionIndex) { + await coll.createIndex({username: 1, sessionid: 1 }, { name: "userSession", unique: true }); + console.log(`Created username/session index for collection ${coll.collectionName}`); + } + + const hasExpiryIndex = await coll.indexExists("expiryIndex"); + if (!hasExpiryIndex) { + await coll.createIndex({ "expireAt": 1 }, { name: "expiryIndex", expireAfterSeconds: 0 }); + console.log(`Created index adding TTL for collection ${coll.collectionName}`); + } + } + + } catch (err) { + console.error("Error with database connection"); + console.error(err); + verboseError(err); + process.exit(1); + } +} + +/* +This function (and the corresponding releaseRefreshLock) provide basic +distributed locking capabilities using the expiry TTLs in mongodb, which +will hopefully be adequate for the purposes in use for here. +*/ +export async function acquireRefreshLock(sessionid, expiresIn, + numRetries=40, msBetweenRetries=500) { + + const expireAt = new Date(Date.now() + expiresIn*1000); + + for (let i = 0; i < numRetries; i++) { + try { + // TTLs indexes are only garbage-collected every minute or so, so manually + // purge any that have expired + await lockCollection.deleteMany({ expireAt: { $lt: new Date() } }) + + await lockCollection.insertOne({ + sessionid, + expireAt + }); + + // No duplicate key error throw by above insert so got lock + return true; + } catch (e) { + if (e.code !== 11000) { + // Not a duplicate key error (which would indicated a failue to acquire the lock) + console.log(e); + } + } + // Wait the specified amount of time before trying again + await new Promise(resolve => { + setTimeout(resolve, msBetweenRetries); + }); + } + + // Failed to acquire lock despite hitting numRetries + return false; +} + +export async function releaseRefreshLock(sessionid) { + // Delete lock record from DB + try { + const deleteResult = await lockCollection.deleteOne({sessionid}); + return deleteResult.acknowledged; + } catch (e) { + console.log(e); + return false; + } +} + +// A symmetric key is used to encrypt the refreshToken at rest, with the key +// only retained by the client +export async function getRefreshToken (username, sessionid, symmKey) { + try { + let record = await refreshTokenCollection.findOne({username,sessionid}); + + if (record?.expireAt < Date.now()) { + // An already expired token that MongoDB hasn't clear out yet + return; + } + + let decipher = createDecipheriv("aes-256-cbc", symmKey, record?.iv.buffer); + let decrypted = decipher.update(record?.refreshToken, "hex", "utf8"); + decrypted += decipher.final("utf8"); + + return decrypted; + } catch (e) { + console.log(e); + return; + } +} + +// A symmetric key is used to encrypt the refreshToken at rest, with the key +// only retained by the client +export async function setRefreshToken(username, sessionid, refreshToken, symmKey, expiresIn) { + try { + // Encrypt the token so gaining access to mongo isn't enough to steal the refresh token + const iv = randomBytes(16); + const cipher = createCipheriv("aes-256-cbc", symmKey, iv); + const encrypted = cipher.update(refreshToken, "utf8", "hex") + cipher.final('hex'); + + const expireAt = new Date(Date.now() + expiresIn*1000); + const updateResult = await refreshTokenCollection.updateOne( + { username,sessionid }, + { $set: { + expireAt, + refreshToken: encrypted, + iv: new Binary(iv) + }}, + { upsert: true } + ); + return updateResult.acknowledged; + } catch (e) { + console.log(e); + return false; + } +} + +export async function getAccessTokenExpiry(username, sessionid) { + try { + // Lookup record in MongoDB using key + let record = await accessTokenLifeTimesCollection.findOne({username, sessionid}); + // Calculate expiry by subtracting the current time from stored key's expiry time + const remaining = floor((record?.expireAt.getTime() - Date.now()) / 1000); + if (remaining > 0) { + return remaining; + } + } catch (e) { + console.log(e); + // Return 0 if record not found or an unexpected error occurs + return 0; + } + // Return 0 if record not found or an unexpected error occurs + return 0; +} + +export async function setAccessTokenExpiry(username, sessionid, expiresIn) { + try { + const expireAt = new Date(Date.now() + expiresIn*1000); + const updateResult = await accessTokenLifeTimesCollection.updateOne( + { username, sessionid }, + { $set: { expireAt } }, + { upsert: true } + ); + return updateResult.acknowledged; + } catch (e) { + console.log(e); + return false; + } +} + + +export async function clearTokens(username, sessionid) { + await Promise.all([ + accessTokenLifeTimesCollection.deleteOne({username, sessionid}) + .catch (e => console.log(e)), + refreshTokenCollection.deleteOne({username, sessionid}) + .catch (e => console.log(e)) + ]) +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 532d863..c4a7110 100644 --- a/src/index.ts +++ b/src/index.ts @@ -85,7 +85,8 @@ if (testUser) { app.get("/dashboard", (req, res) => { res.render("templated", { - clientId: ServerConfig.authProviders.google?.clientId, + googleClientId: ServerConfig.authProviders.google?.clientId, + oidcClientId: ServerConfig.authProviders.oidc?.clientId, hostedDomain: ServerConfig.authProviders.google?.validDomain, bannerColor: ServerConfig.dashboard?.bannerColor, backgroundColor: ServerConfig.dashboard?.backgroundColor, diff --git a/src/types.ts b/src/types.ts index 0f2e1e7..2b9b99b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -40,6 +40,42 @@ export interface CartaExternalAuthConfig { logoutAddress: string; } +export interface CartaOidcAuthConfig { + // URL from which the OpenID Connect endpoint's metadata can be retrieved + idpUrl: string; + // Unique field to be used as username + uniqueField: string; + // Client ID as registered with the OpenID connect endpoint. + clientId: string; + // Client secret as registered with the OpenID connect endpoint. + clientSecret: string; + // User lookup table as text file in format . If no user lookup is needed, leave this blank + scope: string; + // Scopes to request. + userLookupTable?: string; + // Field containing list of groups/roles possessed by the user + groupsField?: string; + // Value to be required as one of the listed user groups/roles in groupsField + requiredGroup?: string; + // Public key used for locally-issued tokens + localPublicKeyLocation: string; + // Private key used for locally-issued tokens + localPrivateKeyLocation: string; + // Algorithm for locally-issued tokens + keyAlgorithm: Algorithm; + // Issuer for locally issued tokens + issuer: string; + // Location of base64-encoded symmetric key for refresh tokens + symmetricKeyLocation: string; + // Type of symmetric key used + // See https://www.iana.org/assignments/jose/jose.xhtml#web-signature-encryption-algorithms + symmetricKeyType: string; + // Recycle access tokens from upstream server if they still have sufficient lifetime remaining (seconds) + cacheAccessTokenMinValidity: number; + // A set of additional parameters to include in token requests + additionalAuthParams: Map; +} + export enum ScriptingAccess { Enabled = "enabled-all-users", Disabled = "disabled-all-users", @@ -53,6 +89,7 @@ export interface CartaServerConfig { ldap?: CartaLdapAuthConfig; google?: CartaGoogleAuthConfig; external?: CartaExternalAuthConfig; + oidc?: CartaOidcAuthConfig; }; database: { uri: string; diff --git a/views/templated.pug b/views/templated.pug index 5a6b752..8ae9ee4 100644 --- a/views/templated.pug +++ b/views/templated.pug @@ -3,9 +3,9 @@ html head meta(charset="UTF-8") title CARTA Dashboard - if clientId + if googleClientId meta(name='google-signin-scope' content='profile email') - meta(name='google-signin-client_id' content=clientId) + meta(name='google-signin-client_id' content=googleClientId) if hostedDomain meta(name='google-signin-hosted_domain' content=hostedDomain) script(src='https://apis.google.com/js/platform.js?onload=initGoogleAuth' async='' defer='') @@ -36,16 +36,19 @@ html br if loginText | !{loginText} - if !clientId + if googleClientId + .sso-buttons + .g-signin2(data-onsuccess='onSignIn' data-theme='dark' data-width="220" data-height="46") + else if oidcClientId + .formcontainer + button.button.button-signin#oidcLogin SIGN IN + else .formcontainer input#username(type="text" placeholder="Username" required="") br input#password(type="password" placeholder="Password" required="") br button.button.button-signin#login SIGN IN - else - .sso-buttons - .g-signin2(data-onsuccess='onSignIn' data-theme='dark' data-width="220" data-height="46") p#login-status(style='display: none') .carta-form(style='display: none') .bodytext