Refresh the OAuth tokens automatically when making rest calls. Fix to remove the password from the api token authentication.

This commit is contained in:
Dean 2022-07-03 21:13:15 +01:00
parent 9972ec403d
commit 1e6845d5cb
10 changed files with 233 additions and 71 deletions

View File

@ -37,53 +37,118 @@ passport.deserializeUser(async (user, done) => {
} }
}) })
//requestAccessStrategy async function refreshOIDCAccessToken(db, chosenConfig, refreshToken) {
//refreshOAuthAccessToken const callbackUrl = await oidc.getCallbackUrl(db, chosenConfig)
let enrichedConfig
//configId for google and OIDC?? let strategy
async function reUpToken(refreshToken, configId) {
const db = getGlobalDB()
console.log(refreshToken, configId)
const config = await getScopedConfig(db, {
type: Configs.OIDC,
group: {}, //ctx.query.group, this was an empty object when authentication initially
})
const chosenConfig = config.configs[0] //.filter((c) => c.uuid === configId)[0]
let callbackUrl = await oidc.oidcCallbackUrl(db, chosenConfig)
//Remote Config
const enrichedConfig = await oidc.fetchOIDCStrategyConfig(
chosenConfig,
callbackUrl
)
const strategy = await oidc.strategyFactory(enrichedConfig, () => {
console.log("saveFn RETURN ARGS", JSON.stringify(arguments))
})
try { try {
enrichedConfig = await oidc.fetchStrategyConfig(chosenConfig, callbackUrl)
if (!enrichedConfig) {
throw new Error("OIDC Config contents invalid")
}
strategy = await oidc.strategyFactory(enrichedConfig)
} catch (err) {
console.error(err)
throw new Error("Could not refresh OAuth Token")
}
refresh.use(strategy, { refresh.use(strategy, {
setRefreshOAuth2() { setRefreshOAuth2() {
return strategy._getOAuth2Client(enrichedConfig) return strategy._getOAuth2Client(enrichedConfig)
}, },
}) })
console.log("Testing")
// By default, the strat calls itself "openidconnect" return new Promise(resolve => {
refresh.requestNewAccessToken(
Configs.OIDC,
refreshToken,
(err, accessToken, refreshToken, params) => {
resolve({ err, accessToken, refreshToken, params })
}
)
})
}
// refresh.requestNewAccessToken( async function refreshGoogleAccessToken(db, config, refreshToken) {
// 'openidconnect', let callbackUrl = await google.getCallbackUrl(db, config)
// refToken, const googleConfig = await google.fetchStrategyConfig(config)
// (err, accessToken, refreshToken) => {
// console.log("REAUTH CB", err, accessToken, refreshToken); let strategy
// }) try {
strategy = await google.strategyFactory(googleConfig, callbackUrl)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
throw new Error("Error constructing OIDC refresh strategy", err) throw new Error("Error constructing OIDC refresh strategy", err)
} }
console.log("end") refresh.use(strategy)
return new Promise(resolve => {
refresh.requestNewAccessToken(
Configs.GOOGLE,
refreshToken,
(err, accessToken, refreshToken, params) => {
resolve({ err, accessToken, refreshToken, params })
}
)
})
}
async function refreshOAuthToken(refreshToken, configType, configId) {
const db = getGlobalDB()
const config = await getScopedConfig(db, {
type: configType,
group: {},
})
let chosenConfig = {}
let refreshResponse
if (configType === Configs.OIDC) {
// configId - retrieved from cookie.
chosenConfig = config.configs.filter(c => c.uuid === configId)[0]
if (!chosenConfig) {
throw new Error("Invalid OIDC configuration")
}
refreshResponse = await refreshOIDCAccessToken(
db,
chosenConfig,
refreshToken
)
} else {
chosenConfig = config
refreshResponse = await refreshGoogleAccessToken(
db,
chosenConfig,
refreshToken
)
}
console.log(JSON.stringify(refreshResponse))
return refreshResponse
}
async function updateUserOAuth(userId, oAuthConfig) {
const details = { ...oAuthConfig }
try {
const db = getGlobalDB()
const dbUser = db.get(userId)
//Do not overwrite the refresh token if a valid one is not provided.
if (typeof details.refreshToken !== "string") {
delete details.refreshToken
}
dbUser.oAuth2 = {
...dbUser.oAuth2,
...details,
}
await db.put(dbUser)
} catch (e) {
console.error("Could not update OAuth details for current user", e)
}
} }
module.exports = { module.exports = {
@ -98,5 +163,6 @@ module.exports = {
authError, authError,
buildCsrfMiddleware: csrf, buildCsrfMiddleware: csrf,
internalApi, internalApi,
reUpToken, refreshOAuthToken,
updateUserOAuth,
} }

View File

@ -128,6 +128,8 @@ module.exports = (
} }
if (!user && tenantId) { if (!user && tenantId) {
user = { tenantId } user = { tenantId }
} else {
delete user.password
} }
// be explicit // be explicit
if (authenticated !== true) { if (authenticated !== true) {

View File

@ -1,6 +1,8 @@
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy
const { ssoCallbackUrl } = require("./utils")
const { authenticateThirdParty } = require("./third-party-common") const { authenticateThirdParty } = require("./third-party-common")
const { Configs } = require("../../../constants")
const environment = require("../../environment")
const buildVerifyFn = saveUserFn => { const buildVerifyFn = saveUserFn => {
return (accessToken, refreshToken, profile, done) => { return (accessToken, refreshToken, profile, done) => {
@ -57,5 +59,19 @@ exports.strategyFactory = async function (config, callbackUrl, saveUserFn) {
) )
} }
} }
exports.fetchStrategyConfig = async function (googleConfig) {
return (
googleConfig || {
clientID: environment.GOOGLE_CLIENT_ID,
clientSecret: environment.GOOGLE_CLIENT_SECRET,
}
)
}
exports.getCallbackUrl = async function (db, config) {
return ssoCallbackUrl(db, config, Configs.GOOGLE)
}
// expose for testing // expose for testing
exports.buildVerifyFn = buildVerifyFn exports.buildVerifyFn = buildVerifyFn

View File

@ -6,7 +6,7 @@ const users = require("../../users")
const { authError } = require("./utils") const { authError } = require("./utils")
const { newid } = require("../../hashing") const { newid } = require("../../hashing")
const { createASession } = require("../../security/sessions") const { createASession } = require("../../security/sessions")
const { getTenantId } = require("../../tenancy") const { getTenantId, getGlobalDB } = require("../../tenancy")
const INVALID_ERR = "Invalid credentials" const INVALID_ERR = "Invalid credentials"
const SSO_NO_PASSWORD = "SSO user does not have a password set" const SSO_NO_PASSWORD = "SSO user does not have a password set"
@ -55,6 +55,20 @@ exports.authenticate = async function (ctx, email, password, done) {
if (await compare(password, dbUser.password)) { if (await compare(password, dbUser.password)) {
const sessionId = newid() const sessionId = newid()
const tenantId = getTenantId() const tenantId = getTenantId()
if (dbUser.provider || dbUser.providerType || dbUser.pictureUrl) {
delete dbUser.provider
delete dbUser.providerType
delete dbUser.pictureUrl
try {
const db = getGlobalDB()
await db.put(dbUser)
} catch (err) {
console.error("OAuth elements could not be purged")
}
}
await createASession(dbUser._id, { sessionId, tenantId }) await createASession(dbUser._id, { sessionId, tenantId })
dbUser.token = jwt.sign( dbUser.token = jwt.sign(

View File

@ -2,6 +2,7 @@ const fetch = require("node-fetch")
const OIDCStrategy = require("@techpass/passport-openidconnect").Strategy const OIDCStrategy = require("@techpass/passport-openidconnect").Strategy
const { authenticateThirdParty } = require("./third-party-common") const { authenticateThirdParty } = require("./third-party-common")
const { ssoCallbackUrl } = require("./utils") const { ssoCallbackUrl } = require("./utils")
const { Configs } = require("../../../constants")
const buildVerifyFn = saveUserFn => { const buildVerifyFn = saveUserFn => {
/** /**
@ -93,14 +94,16 @@ function validEmail(value) {
exports.strategyFactory = async function (config, saveUserFn) { exports.strategyFactory = async function (config, saveUserFn) {
try { try {
const verify = buildVerifyFn(saveUserFn) const verify = buildVerifyFn(saveUserFn)
return new OIDCStrategy(config, verify) const strategy = new OIDCStrategy(config, verify)
strategy.name = "oidc"
return strategy
} catch (err) { } catch (err) {
console.error(err) console.error(err)
throw new Error("Error constructing OIDC authentication strategy", err) throw new Error("Error constructing OIDC authentication strategy", err)
} }
} }
export const fetchOIDCStrategyConfig = async (config, callbackUrl) => { exports.fetchStrategyConfig = async function (config, callbackUrl) {
try { try {
const { clientID, clientSecret, configUrl } = config const { clientID, clientSecret, configUrl } = config
@ -136,8 +139,8 @@ export const fetchOIDCStrategyConfig = async (config, callbackUrl) => {
} }
} }
export const oidcCallbackUrl = async (db, config) => { exports.getCallbackUrl = async function (db, config) {
return ssoCallbackUrl(db, config, "oidc") return ssoCallbackUrl(db, config, Configs.OIDC)
} }
// expose for testing // expose for testing

View File

@ -85,11 +85,11 @@ filterTests(['all'], () => {
cy.get(interact.APP_TABLE_APP_NAME).click({ force: true }) cy.get(interact.APP_TABLE_APP_NAME).click({ force: true })
}) })
cy.get(interact.DEPLOYMENT_TOP_NAV).click() cy.get(interact.DEPLOYMENT_TOP_GLOBE).should("exist").click({ force: true })
cy.get(interact.PUBLISH_POPOVER_ACTION).click({ force: true })
cy.get(interact.UNPUBLISH_MODAL) cy.get("[data-cy='publish-popover-menu']")
.within(() => { .within(() => {
cy.get(interact.CONFIRM_WRAP_BUTTON).click({ force: true }) cy.get(interact.PUBLISH_POPOVER_ACTION).click({ force: true })
}) })
cy.get(interact.UNPUBLISH_MODAL).should("be.visible") cy.get(interact.UNPUBLISH_MODAL).should("be.visible")

View File

@ -9,7 +9,7 @@ import { getAppDB } from "@budibase/backend-core/context"
import { quotas } from "@budibase/pro" import { quotas } from "@budibase/pro"
import { events } from "@budibase/backend-core" import { events } from "@budibase/backend-core"
import { getCookie } from "@budibase/backend-core/utils" import { getCookie } from "@budibase/backend-core/utils"
import { Cookies } from "@budibase/backend-core/constants" import { Cookies, Configs } from "@budibase/backend-core/constants"
const Runner = new Thread(ThreadType.QUERY, { const Runner = new Thread(ThreadType.QUERY, {
timeoutMs: QUERY_THREAD_TIMEOUT || 10000, timeoutMs: QUERY_THREAD_TIMEOUT || 10000,
@ -112,6 +112,21 @@ export async function find(ctx: any) {
ctx.body = query ctx.body = query
} }
//Required to discern between OIDC OAuth config entries
function getOAuthConfigCookieId(ctx: any) {
if (ctx.user.providerType === Configs.OIDC) {
return getCookie(ctx, Cookies.OIDC_CONFIG)
}
}
function getAuthConfig(ctx: any) {
const authCookie = getCookie(ctx, Cookies.Auth)
let authConfigCtx: any = {}
authConfigCtx["configId"] = getOAuthConfigCookieId(ctx)
authConfigCtx["sessionId"] = authCookie ? authCookie.sessionId : null
return authConfigCtx
}
export async function preview(ctx: any) { export async function preview(ctx: any) {
const db = getAppDB() const db = getAppDB()
@ -121,9 +136,7 @@ export async function preview(ctx: any) {
// this stops dynamic variables from calling the same query // this stops dynamic variables from calling the same query
const { fields, parameters, queryVerb, transformer, queryId } = query const { fields, parameters, queryVerb, transformer, queryId } = query
//check for oAuth elements here? const authConfigCtx: any = getAuthConfig(ctx)
const configId = getCookie(ctx, Cookies.OIDC_CONFIG)
console.log(configId)
try { try {
const runFn = () => const runFn = () =>
@ -135,6 +148,10 @@ export async function preview(ctx: any) {
parameters, parameters,
transformer, transformer,
queryId, queryId,
ctx: {
user: ctx.user,
auth: { ...authConfigCtx },
},
}) })
const { rows, keys, info, extra } = await quotas.addQuery(runFn) const { rows, keys, info, extra } = await quotas.addQuery(runFn)
await events.query.previewed(datasource, query) await events.query.previewed(datasource, query)
@ -177,6 +194,9 @@ async function execute(ctx: any, opts = { rowsOnly: false }) {
parameters: enrichedParameters, parameters: enrichedParameters,
transformer: query.transformer, transformer: query.transformer,
queryId: ctx.params.queryId, queryId: ctx.params.queryId,
ctx: {
user: ctx.user,
},
}) })
const { rows, pagination, extra } = await quotas.addQuery(runFn) const { rows, pagination, extra } = await quotas.addQuery(runFn)

View File

@ -8,3 +8,4 @@ declare module "@budibase/backend-core/constants"
declare module "@budibase/backend-core/auth" declare module "@budibase/backend-core/auth"
declare module "@budibase/backend-core/sessions" declare module "@budibase/backend-core/sessions"
declare module "@budibase/backend-core/encryption" declare module "@budibase/backend-core/encryption"
declare module "@budibase/backend-core/utils"

View File

@ -4,7 +4,12 @@ const ScriptRunner = require("../utilities/scriptRunner")
const { integrations } = require("../integrations") const { integrations } = require("../integrations")
const { processStringSync } = require("@budibase/string-templates") const { processStringSync } = require("@budibase/string-templates")
const { doInAppContext, getAppDB } = require("@budibase/backend-core/context") const { doInAppContext, getAppDB } = require("@budibase/backend-core/context")
// const { reUpToken } = require("@budibase/backend-core/auth") const {
refreshOAuthToken,
updateUserOAuth,
} = require("@budibase/backend-core/auth")
const { getGlobalIDFromUserMetadataID } = require("../db/utils")
const { isSQL } = require("../integrations/utils") const { isSQL } = require("../integrations/utils")
const { const {
enrichQueryFields, enrichQueryFields,
@ -22,20 +27,19 @@ class QueryRunner {
this.queryId = input.queryId this.queryId = input.queryId
this.noRecursiveQuery = flags.noRecursiveQuery this.noRecursiveQuery = flags.noRecursiveQuery
this.cachedVariables = [] this.cachedVariables = []
// Additional context items for enrichment
this.ctx = input.ctx
// allows the response from a query to be stored throughout this // allows the response from a query to be stored throughout this
// execution so that if it needs to be re-used for another variable // execution so that if it needs to be re-used for another variable
// it can be // it can be
this.queryResponse = {} this.queryResponse = {}
this.hasRerun = false this.hasRerun = false
this.hasRefreshedOAuth = false
} }
async execute() { async execute() {
let { datasource, fields, queryVerb, transformer } = this let { datasource, fields, queryVerb, transformer } = this
// if(this.ctx.user.oauth2?.refreshToken){
// reUpToken(this.ctx.user.oauth2?.refreshToken)
// }
const Integration = integrations[datasource.source] const Integration = integrations[datasource.source]
if (!Integration) { if (!Integration) {
throw "Integration type does not exist." throw "Integration type does not exist."
@ -79,15 +83,24 @@ class QueryRunner {
} }
// if the request fails we retry once, invalidating the cached value // if the request fails we retry once, invalidating the cached value
if (info && info.code >= 400 && !this.hasRerun) {
if ( if (
info && this.ctx.user?.provider &&
info.code >= 400 && info.code === 401 &&
this.cachedVariables.length > 0 && !this.hasRefreshedOAuth
!this.hasRerun
) { ) {
// return { info } // Attempt to refresh the access token from the provider
this.hasRefreshedOAuth = true
const authResponse = await this.refreshOAuth2(this.ctx)
if (!authResponse || authResponse.err) {
// In this event the user may have oAuth issues that
// could require re-authenticating with their provider.
throw new Error("OAuth2 access token could not be refreshed")
}
}
this.hasRerun = true this.hasRerun = true
// invalidate the cache value
await threadUtils.invalidateDynamicVariables(this.cachedVariables) await threadUtils.invalidateDynamicVariables(this.cachedVariables)
return this.execute() return this.execute()
} }
@ -133,6 +146,31 @@ class QueryRunner {
).execute() ).execute()
} }
async refreshOAuth2(ctx) {
const { oauth2, providerType, _id } = ctx.user
const { configId } = ctx.auth
if (!providerType || !oauth2?.refreshToken) {
console.error("No refresh token found for authenticated user")
return
}
const resp = await refreshOAuthToken(
oauth2.refreshToken,
providerType,
configId
)
// Refresh session flow. Should be in same location as refreshOAuthToken
// There are several other properties available in 'resp'
if (!resp.error) {
const globalUserId = getGlobalIDFromUserMetadataID(_id)
await updateUserOAuth(globalUserId, resp)
}
return resp
}
async getDynamicVariable(variable) { async getDynamicVariable(variable) {
let { parameters } = this let { parameters } = this
const queryId = variable.queryId, const queryId = variable.queryId,

View File

@ -70,7 +70,7 @@ async function authInternal(ctx: any, user: any, err = null, info = null) {
export const authenticate = async (ctx: any, next: any) => { export const authenticate = async (ctx: any, next: any) => {
return passport.authenticate( return passport.authenticate(
"local", "local",
async (err: any, user: User, info: any) => { async (err: any, user: any, info: any) => {
await authInternal(ctx, user, err, info) await authInternal(ctx, user, err, info)
await context.identity.doInUserContext(user, async () => { await context.identity.doInUserContext(user, async () => {
await events.auth.login("local") await events.auth.login("local")
@ -197,7 +197,9 @@ export const googlePreAuth = async (ctx: any, next: any) => {
const strategy = await google.strategyFactory(config, callbackUrl, users.save) const strategy = await google.strategyFactory(config, callbackUrl, users.save)
return passport.authenticate(strategy, { return passport.authenticate(strategy, {
scope: ["profile", "email"], scope: ["profile", "email", "https://www.googleapis.com/auth/spreadsheets"],
accessType: "offline",
prompt: "consent",
})(ctx, next) })(ctx, next)
} }
@ -235,7 +237,7 @@ export const oidcStrategyFactory = async (ctx: any, configId: any) => {
let callbackUrl = await exports.oidcCallbackUrl(chosenConfig) let callbackUrl = await exports.oidcCallbackUrl(chosenConfig)
//Remote Config //Remote Config
const enrichedConfig = await oidc.fetchOIDCStrategyConfig( const enrichedConfig = await oidc.fetchStrategyConfig(
chosenConfig, chosenConfig,
callbackUrl callbackUrl
) )