Skip to content

Commit

Permalink
Add Electron OAuth guide (#1111)
Browse files Browse the repository at this point in the history
  • Loading branch information
pilcrowonpaper authored Sep 14, 2023
1 parent 8393cef commit 1b24b5a
Show file tree
Hide file tree
Showing 43 changed files with 9,648 additions and 44 deletions.
311 changes: 311 additions & 0 deletions documentation/content/guidebook/github-oauth-native/electron.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,311 @@
---
title: "Github OAuth in Electron"
description: "Learn how to implement Github OAuth in Electron desktop applications"
---

> These guides are not beginner friendly and do not cover the basics of Lucia. We recommend reading the [Github OAuth](http://localhost:3000/guidebook/github-oauth) guide for regular websites first.
We'll be using bearer tokens instead of cookies to validate users. For the most part, authenticating the user is identical to regular web applications. The user is redirected to Github, then back to your server with a `code`, which is then exchanged for an access token, and a new user/session is created.

To send the session token (ie. session id) from the server back to our application, we'll be using deep-links which allow us to open applications using a url.

### Clone project

You can get started immediately by cloning the [example](https://github.com/pilcrowOnPaper/lucia/tree/main/examples/electron/github-oauth) from the repository.

```
npx degit pilcrowonpaper/lucia/examples/electron/github-oauth <directory_name>
```

Alternatively, you can [open it in StackBlitz](https://stackblitz.com/github/pilcrowOnPaper/lucia/tree/main/examples/electron/github-oauth).

## Server

Make sure you've installed `lucia` and `@lucia-auth/oauth`, create 4 API routes:

- GET `/user`: Returns the current user
- GET `/login/github`: Redirects the user to the Github authorization url
- GET `/login/github/callback`: Handles callback from Github and redirects the user to the localhost server with the session id
- POST `/logout`: Handles logouts

This example uses [Hono](https://hono.dev) but you should be able to easily convert it to whatever framework you use.

There are few key differences between the code for regular web applications. First, we'll be using bearer tokens instead of cookies. As such, [`AuthRequest.validateBearerToken()`](/reference/lucia/interfaces/authrequest#validatebearertoken) is used instead of `AuthRequest.validate()`. We'll send the user back to the application with a deep-link, where the session token is stored as a search params. The guide uses `electron-app` protocol as an example, but you can configure it in your Electron application.

```ts
import { lucia } from "lucia";
import { github } from "@lucia-auth/oauth/providers";

export const auth = lucia({
// ...
});

export type Auth = typeof auth;

export const githubAuth = github(auth, {
clientId,
clientSecret
});
```

```ts
import { auth, githubAuth } from "./auth";
import { OAuthRequestError } from "@lucia-auth/oauth";

import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { getCookie, setCookie } from "hono/cookie";

const app = new Hono();

app.get("/user", async (c) => {
const authRequest = auth.handleRequest(c);
const session = await authRequest.validateBearerToken();
if (!session) {
return c.newResponse(null, 401);
}
return c.json(session.user);
});

app.get("/login/github", async (c) => {
const [authorizationUrl, state] = await githubAuth.getAuthorizationUrl();
setCookie(c, "github_oauth_state", state, {
path: "/",
maxAge: 60 * 10, // 10 min
httpOnly: true,
secure: process.env.NODE_ENV === "production"
});
return c.redirect(authorizationUrl.toString());
});

app.get("/login/github/callback", async (c) => {
const url = new URL(c.req.url);
const code = url.searchParams.get("code");
if (!code) return c.newResponse(null, 400);
const state = url.searchParams.get("state");
const storedState = getCookie(c, "github_oauth_state");
if (!state || !storedState || state !== storedState) {
return c.newResponse(null, 400);
}
try {
const { getExistingUser, githubUser, createUser } =
await githubAuth.validateCallback(code);
let user = await getExistingUser();
if (!user) {
user = await createUser({
attributes: {
username: githubUser.login
}
});
}
const session = await auth.createSession({
userId: user.userId,
attributes: {}
});
return c.redirect(
`electron-app://login?session_token=${session.sessionId}`
);
} catch (e) {
console.log(e);
if (e instanceof OAuthRequestError) {
// invalid code
return c.newResponse(null, 400);
}
return c.newResponse(null, 500);
}
});

app.post("/logout", async (c) => {
const authRequest = auth.handleRequest(c);
const session = await authRequest.validateBearerToken();
if (!session) return c.newResponse(null, 401);
await auth.invalidateSession(session.sessionId);
return c.newResponse(null, 200);
});

serve(app);
```

## Electron app

This example uses [Electron Forge](https://www.electronforge.io), which currently is the recommended way to package Electron apps.

### Setup deep linking

In `forge.config.ts`, update `packagerConfig.protocols` and `mimeType` for `MakerDeb`. This guide uses `electron-app` as an example.

```ts
// forge.config.ts
import type { ForgeConfig } from "@electron-forge/shared-types";

// ...
import { MakerDeb } from "@electron-forge/maker-deb";

const config: ForgeConfig = {
packagerConfig: {
protocols: [
{
name: "Electron app",
schemes: ["electron-app"]
}
]
},
makers: [
// ...
new MakerDeb({
options: {
mimeType: ["x-scheme-handler/electron-app"]
}
})
]
// ...
};

export default config;
```

In `src/main.ts`, set the default protocol client with `App.setAsDefaultProtocolClient()`.

```ts
// src/main.ts
import { app } from "electron";
import path from "path";

if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient("electron-app", process.execPath, [
path.resolve(process.argv[1])
]);
}
} else {
app.setAsDefaultProtocolClient("electron-app");
}
```

### Setup IPC listeners

These will be invoked from `src/preload.ts`.

```ts
// src/main.ts
import { app, BrowserWindow, shell, net } from "electron";

ipcMain.handle("auth:signInWithGithub", () => {
shell.openExternal("http://localhost:3000/login/github");
});

ipcMain.handle("auth:getUser", async (e, sessionToken: string) => {
const response = await net.fetch("http://localhost:3000/user", {
headers: {
Authorization: `Bearer ${sessionToken}`
}
});
if (!response.ok) {
return null;
}
return await response.json();
});

ipcMain.handle("auth:signOut", async (e, sessionToken: string) => {
await net.fetch("http://localhost:3000/logout", {
method: "POST",
headers: {
Authorization: `Bearer ${sessionToken}`
}
});
});
```

### Setup login callback

Listen for the deep-link callback, parse the url, and send the token to the renderer with the `auth-state-update` event (`preload.ts`).

```ts
// src/main.ts
import { app, BrowserWindow, ipcMain, shell, net } from "electron";

// new BrowserWindow() instance
let mainWindow: BrowserWindow;

// for windows, linux
app.on("second-instance", (_, commandLine) => {
// Someone tried to run a second instance, we should focus our window.
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
const url = commandLine.at(-1);
handleDeepLinkCallback(url);
});

// macos
app.on("open-url", (_, url) => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
handleDeepLinkCallback(url);
});

const handleDeepLinkCallback = (url: string) => {
if (!url.startsWith("electron-app://login?")) return;
const params = new URLSearchParams(url.replace("electron-app://login?", ""));
const sessionToken = params.get("session_token");
if (!sessionToken) return;
mainWindow.webContents.send("auth-state-update", sessionToken);
};

const createWindow = () => {
mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, "preload.js")
}
});
// ...
};
```

### Frontend

Listen for the `auth-state-update` event sent by `main.ts`, and get the user and store token as needed. While we're there are ways to store tokens in with obfuscation, security is comparable to using `localStorage` API in a browser.

```ts
// src/preload.ts
import { ipcRenderer } from "electron";

ipcRenderer.on("auth-state-update", async (e, sessionToken: string | null) => {
if (sessionToken) {
const user = await getUser(sessionToken);
if (user) {
localStorage.setItem("session_token", sessionToken);
// signed in
} else {
localStorage.removeItem("session_token");
}
} else {
localStorage.removeItem("session_token");
}
});

const signInWithGithub = async () => {
await ipcRenderer.invoke("auth:signInWithGithub");
};

const getUser = async (sessionToken: string): Promise<User | null> => {
return await ipcRenderer.invoke("auth:getUser", sessionToken);
};

const signOut = async () => {
const sessionToken = localStorage.getItem("session_token");
if (!sessionToken) return;
await ipcRenderer.invoke("auth:signOut", sessionToken);
renderUserProfile(null);
localStorage.removeItem("session_token");
};

type User = {
userId: string;
username: string;
};
```
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ description: "Learn how to implement Github OAuth in desktop and mobile applicat

These guides are not beginner friendly and do not cover the basics of Lucia. We recommend reading the [Github OAuth]() guide for regular websites first. In addition, Lucia is a library to be used in a server environment, and as such all these guides will require a TypeScript server.

- [Electron](/guidebook/github-oauth-native/electron)
- [Tauri](/guidebook/github-oauth-native/tauri)
- (more in progress)
4 changes: 3 additions & 1 deletion documentation/src/pages/guidebook/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import ArticleIcon from "@icons/ArticleIcon.astro";
import { getPages } from "@utils/content";
const allPages = await getPages("guidebook");
const parentPages = allPages.filter((page) => page.frameworkId === null);
const parentPages = allPages.filter(
(page) => page.pathname.split("/").length === 2
);
---

<BaseLayout title="The Lucia Guidebook">
Expand Down
5 changes: 1 addition & 4 deletions examples/astro/github-oauth/README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
# GitHub OAuth example with Lucia and Astro

This example uses SQLite3 with `better-sqlite3`.
This example uses SQLite3 with `better-sqlite3`. Make sure to setup your `.env` file.

```bash
# install dependencies
pnpm i

# setup .env
pnpm setup-env

# run dev server
pnpm dev
```
Expand Down
1 change: 0 additions & 1 deletion examples/astro/github-oauth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
"type": "module",
"version": "0.0.1",
"scripts": {
"setup-env": "cp .env.example .env",
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
Expand Down
43 changes: 43 additions & 0 deletions examples/electron/github-oauth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# GitHub OAuth example with Lucia and Electron

This example has 2 parts: the Electron application and the TS server with Lucia. Uses SQLite3 with `better-sqlite3` as the database.

## App

Inside `app` directory. **Electron Forge does not support PNPM.**

```bash
# install dependencies
npm i

# run dev server
npm run start
```

## Server

Inside `server` directory. Make sure to setup your `.env` file.

```bash
# install dependencies
pnpm i

# run server on port 3000
pnpm start
```

## Setup GitHub OAuth

[Create a new GitHub OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app). The redirect uri should be set to `localhost:5173/login/github/callback`. Copy and paste the client id and secret into `.env`.

```bash
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
```

## User schema

| id | type |
| ---------- | -------- |
| `id` | `string` |
| `username` | `string` |
Loading

0 comments on commit 1b24b5a

Please sign in to comment.