saltyshiomix / nextron

⚡ Next.js + Electron ⚡
https://npm.im/nextron
MIT License
3.69k stars 215 forks source link

How to implement Google OAuth? #433

Open brunolm opened 6 months ago

brunolm commented 6 months ago

I couldn't figure out if the bundled app (production mode) exposes the API in any port, so assuming it doesn't here's how I implemented OAuth.

Looking at examples on Google's documentation I found a C# code that gets an unused port and starts listening for requests on that port, then passes localhost:PORT as a callback_uri to Google. Once Google calls this address C# detects the request and extracts the code.

With the same logic I implemented in Nextron with:

get-unused-port.ts

import * as net from 'net'

export function getUnusedPort() {
  return new Promise<number>((resolve) => {
    const server = net.createServer()
    server.listen(0, () => {
      const port = (server.address() as net.AddressInfo).port
      server.close(() => {
        resolve(port)
      })
    })
  })
}

get-params.ts

import * as http from 'http'

export const getParams = async ({ unusedPort }) => {
  return new Promise<{ code: string; scope: string }>((resolve) => {
    const server = http.createServer((req, res) => {
      res.writeHead(200, { 'Content-Type': 'application/json' })

      const queryParameters = new URLSearchParams(req.url.split('?')[1])
      const values = Object.fromEntries(queryParameters)

      res.end(JSON.stringify(values))
      resolve(values as any)
      server.close()
    })
    server.listen(unusedPort)
  })
}

Then in my main file I call Google Auth with the temp port and when Google calls it I get the tokens

  const unusedPort = await getUnusedPort()

  const initOAuthClient = () => {
    return new OAuth2Client({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
      redirectUri: `http://localhost:${unusedPort}/`,
    })
  }
  oAuthClient = initOAuthClient()
  const url = oAuthClient.generateAuthUrl({
    scope: ['...'],
  })

  const open = (await dynamicImport('open')) as (url: string) => Promise<ChildProcess>

  // opens the user default browser on Google Auth url
  const proc = await open(url)

  // waits until Google calls the temp local port
  const result = await getParams({ unusedPort })

  // gets access_token
  const r = await oAuthClient.getToken(result.code)
  oAuthClient.setCredentials(r.tokens)

  proc.kill(0)

  event.reply(authReplySignal, {
    data: {
      ...r.tokens,
    },
  })

Is this the right way to do it? Should I be using renderer/pages/api instead? How would I make it work when it's deployed?

bm777 commented 6 months ago

@brunolm Electron in production mode can't handle callback URL because Electron can handle only static files, sorry for inconvenience. But you can use deeplink as a workaround or the following medium.

Use the same analogy in NExtron using IPC communication: electron+googleAuth

I think, I'm gonna make a tutorial on how to do it with nextron :)

brunolm commented 6 months ago

/pages/api only work in dev, to make it work in the bundled version you need this code in background.ts

import dotenv from 'dotenv'
import { BrowserWindow, app, ipcMain } from 'electron'
import log from 'electron-log/main'
import * as http from 'http'
import * as net from 'net'
import next from 'next'
import path from 'path'
import { parse } from 'url'

const logger = log.scope('main')

const load = async () => {
  await app.whenReady()

  logger.info('App ready')

  const rendererPath = path.join(app.getAppPath(), 'renderer')
  const nextApp = next({ dev: !isProd, dir: rendererPath })
  const handle = nextApp.getRequestHandler()

  await nextApp.prepare()

  server = http.createServer((req: any, res: any) => {
    logger.info(`HTTP Request [${req.method}] ${req.url}`)
    const parsedUrl = parse(req.url, true)

    handle(req, res, parsedUrl)
  })

  const serverPort = await new Promise((resolve) => {
    server.listen(0, () => {
      const address = server.address() as net.AddressInfo
      logger.info(`  >> Ready on http://localhost:${address.port}`)
      logger.info(`Renderer path: ${rendererPath}`)

      resolve((server.address() as net.AddressInfo).port)
    })
  })

  global.baseUrl = `http://localhost:${serverPort}`
  global.port = serverPort
  process.env.NEXTAUTH_URL = global.baseUrl

  const mainWindow = await createMainWindow({
    url: global.baseUrl,
    isProd,
    allAppWindows,
    appInfo: {
      baseUrl: global.baseUrl,
      port: global.port,
    },
  })

  // do not use dev port or app://
  // use your server instead
  await mainWindowawait mainWindow.loadURL(`${global.baseUrl}/home`)
}

logger.info('Loading app...')
load()

With this you can use Next Auth normally (and the API folder)

bm777 commented 6 months ago

@brunolm Happy that you solved your issue. You are embedding a server in your bundled app. In my case, I detached the webserver in Rust, for memory purposes (to avoid memory leaks). If your solution works -> it is fine.

bm777 commented 6 months ago

And i will try your solution :)

pixelass commented 2 months ago

We also use deeplinks for authorization:

[!NOTE] it is currently just a preparation for later but gets an accessToken from the deepLink URL and then stores it in our encrypted electron-store.

I hope it helps