Skip to content

Commit 6ca2a7d

Browse files
authored
feat(auth): setup OpenAuth (#94)
* feat(auth): try out setting up OpenAuth Doesn’t work, needs to run from `/` * refactor: add i18n types + small code changes * fix: ui adjustments * feat: better handling (#83) * feat: add tryCatch helper function * feat: add neverthrow + tryCatch helper * feat: reorganise api routes for better endpoints protections * feat: add caddy proxy to dev environment everything can be served directly from `localhost` API routes will be proxied to :1993 and web routes to :3000 incomplete attempt to get OpenAuth working from another root * fix: malformed package.json * chore: add `unstorage` patch for use with OpenAuth * chore: add `openauth` patch Waiting for PR [#236](sst/openauth#236) to be merged * chore: remove unused login and signup routes this is now managed by OpenAuth * feat: add `APP_URL` config option + include api version in `API_BASE` * chore: add logo to public dir * fix: update unstorage adapter * feat: get OpenAuth working properly * feat: handle config errors gracefully Now pretty printing errors for better usability and clarity for user * chore: adjust tests to new config requirements * ci: run on all branches * fix(config): api base base computation * feat: implement logout * refactor: move `setTokens` * feat: implement auth check on API * chore: use unstorage fsDriver instead of memory signing key changes on every server reload is highly irritating * fix: correct config test * chore: remove unused * fix: type mismatch * fix: locale types
1 parent c247d85 commit 6ca2a7d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+9264
-371
lines changed

.github/workflows/pipeline.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name: Karr CI
33
on:
44
push:
55
branches:
6-
- main
6+
- "**"
77
tags-ignore:
88
- "v*" # Ignore version tags
99
pull_request:

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ yarn-error.log*
4040
logs
4141
*.log
4242
.pnpm-debug.log*
43+
**/tmp
4344

4445
# Misc
4546
.DS_Store

apps/api/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,19 @@
1616
},
1717
"dependencies": {
1818
"@hono/node-server": "catalog:",
19+
"@karr/auth": "workspace:*",
1920
"@karr/config": "workspace:*",
2021
"@karr/db": "workspace:*",
2122
"@karr/util": "workspace:*",
23+
"@openauthjs/openauth": "catalog:",
2224
"drizzle-kit": "catalog:",
2325
"drizzle-orm": "catalog:",
2426
"drizzle-zod": "catalog:",
2527
"hono": "catalog:",
2628
"neverthrow": "catalog:",
2729
"ofetch": "catalog:",
2830
"postgres": "catalog:",
31+
"unstorage": "catalog:",
2932
"zod": "catalog:"
3033
},
3134
"devDependencies": {

apps/api/src/lib/auth.ts

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import crypto from "node:crypto"
2-
import { and, eq } from "drizzle-orm"
2+
import { eq } from "drizzle-orm"
3+
import { Context } from "hono"
4+
import { getCookie } from "hono/cookie"
35
import { err, ok } from "neverthrow"
46

57
import db from "@karr/db"
68
import { accountsTable } from "@karr/db/schemas/accounts.js"
79
import { tryCatch } from "@karr/util"
810
import logger from "@karr/util/logger"
11+
import { client } from "@karr/auth/client"
12+
import { subjects } from "@karr/auth/subjects"
13+
import { setTokens } from "@/routes/auth/issuer"
914

1015
export async function authenticate(email: string, password: string) {
1116
const user = await tryCatch(
@@ -92,33 +97,32 @@ export async function register(email: string, password: string) {
9297
return ok(token)
9398
}
9499

95-
export async function isAuthenticated(userId: string, token: string) {
96-
const user = await tryCatch(
97-
db
98-
.select({
99-
id: accountsTable.id,
100-
email: accountsTable.email,
101-
blocked: accountsTable.blocked,
102-
verified: accountsTable.verified
103-
})
104-
.from(accountsTable)
105-
.where(and(eq(accountsTable.token, token), eq(accountsTable.id, userId)))
106-
.limit(1)
107-
)
100+
/**
101+
* Check if the user is authenticated
102+
* @param ctx The request context
103+
* @returns true if the user is authenticated
104+
*/
105+
export async function isAuthenticated(ctx: Context) {
106+
const accessToken = getCookie(ctx, "access_token")
107+
const refreshToken = getCookie(ctx, "refresh_token")
108108

109-
if (user.error) {
109+
if (!accessToken) {
110110
return false
111111
}
112112

113-
if (!user || user.value.length === 0 || user.value[0] === undefined) {
114-
return false
115-
}
113+
const verified = await client.verify(subjects, accessToken, {
114+
refresh: refreshToken
115+
})
116116

117-
if (user.value[0].id !== userId) {
117+
if (verified.err) {
118+
console.error("Error verifying token:", verified.err)
118119
return false
119120
}
121+
if (verified.tokens) {
122+
setTokens(ctx, verified.tokens)
123+
}
120124

121-
return true
125+
return verified.subject
122126
}
123127

124128
export async function logout(token: string) {

apps/api/src/lib/helpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export async function handleRequest<T>(c: Context, fn: () => Promise<T>) {
6969

7070
return c.json({
7171
timestamp: new Date().getTime(),
72-
data: out
72+
data: out.value
7373
})
7474
}
7575

apps/api/src/lib/types.d.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ import type { specialStatusTable } from "@/db/schemas/specialstatus"
55
import type { userPrefsTable } from "@/db/schemas/userprefs"
66
import type { usersTable } from "@/db/schemas/users"
77

8+
export type UserSubject = {
9+
type: "user"
10+
properties: {
11+
userID: string
12+
}
13+
}
14+
15+
export type AppVariables = {
16+
userSubject?: UserSubject
17+
// Add other context variables here if needed
18+
}
19+
820
export interface DataResponse<T> {
921
timestamp?: number
1022
data: T

apps/api/src/main.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { serve } from "@hono/node-server"
2-
import type { Hono } from "hono"
32

43
import { API_PORT, LOG_LEVEL, logLevels, PRODUCTION } from "@karr/config"
54
import { drizzleMigrate } from "@karr/db/migrate"
@@ -25,7 +24,7 @@ try {
2524
await drizzleMigrate()
2625

2726
// Build the server
28-
const app: Hono = build()
27+
const app = build()
2928

3029
// Start the server
3130
serve({

apps/api/src/routes/auth/issuer.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { issuer } from "@openauthjs/openauth"
2+
import { GithubProvider } from "@openauthjs/openauth/provider/github"
3+
import { PasswordProvider } from "@openauthjs/openauth/provider/password"
4+
import { PasswordUI } from "@openauthjs/openauth/ui/password"
5+
import { Select } from "@openauthjs/openauth/ui/select"
6+
import type { Theme } from "@openauthjs/openauth/ui/theme"
7+
import { Tokens } from "@openauthjs/openauth/client"
8+
import { setCookie } from "hono/cookie"
9+
import { Context } from "hono"
10+
import fsDriver from "unstorage/drivers/fs"
11+
12+
import { callbackUrl, client } from "@karr/auth/client"
13+
import { subjects } from "@karr/auth/subjects"
14+
import { API_BASE } from "@karr/config"
15+
import { logger } from "@karr/util/logger"
16+
17+
import { responseErrorObject } from "@/lib/helpers"
18+
import { UnStorage } from "./unstorage-adapter"
19+
20+
async function getUser(provider: string, email: string) {
21+
console.log(provider, email)
22+
// Get user from database and return user ID
23+
return "123"
24+
}
25+
26+
const THEME_OPENAUTH: Theme = {
27+
title: "Karr Auth",
28+
logo: "/logo-tmp.jpg",
29+
background: {
30+
dark: "hsl(132 2% 10%)",
31+
light: "hsl(140 20% 97%)"
32+
},
33+
primary: {
34+
dark: "hsl(132 74% 32%)",
35+
light: "hsl(132 64% 32%)"
36+
},
37+
font: {
38+
family: "Varela Round, sans-serif"
39+
},
40+
css: ``
41+
}
42+
43+
const app = issuer({
44+
basePath: `${API_BASE}/auth`,
45+
select: Select({
46+
providers: {
47+
github: {
48+
display: "GitHub"
49+
},
50+
password: {
51+
display: "a password"
52+
}
53+
}
54+
}),
55+
providers: {
56+
github: GithubProvider({
57+
clientSecret: "fc0f07b9daf343cd160226990cb55519c5cfb728",
58+
clientID: "Ov23lic9kjugi9TMB1oj",
59+
scopes: ["email"]
60+
}),
61+
password: PasswordProvider(
62+
PasswordUI({
63+
copy: {
64+
error_email_taken: "This email is already taken."
65+
},
66+
sendCode: async (email, code) => {
67+
console.log(email, code)
68+
}
69+
})
70+
)
71+
},
72+
storage: UnStorage({
73+
driver: fsDriver({ base: "./tmp" })
74+
}),
75+
subjects,
76+
theme: THEME_OPENAUTH,
77+
async allow(input, _req) {
78+
console.log("Allow check:", {
79+
audience: input.audience,
80+
clientID: input.clientID,
81+
redirectURI: input.redirectURI
82+
})
83+
84+
return true // TODO: restrict. For testing, allow all
85+
},
86+
async success(ctx, value) {
87+
logger.info("Success", value)
88+
if (value.provider === "github") {
89+
console.log(value.tokenset.access)
90+
return ctx.subject("user", {
91+
userID: "test"
92+
})
93+
} else if (value.provider === "password") {
94+
const userID = await getUser(value.provider, value.email)
95+
return ctx.subject("user", {
96+
userID
97+
})
98+
}
99+
100+
throw new Error("Invalid provider")
101+
}
102+
})
103+
104+
app.get("/callback", async (ctx) => {
105+
const url = new URL(ctx.req.url)
106+
const code = ctx.req.query("code")
107+
const error = ctx.req.query("error")
108+
const next = ctx.req.query("next") ?? `${url.origin}/`
109+
110+
if (error)
111+
return responseErrorObject(ctx, {
112+
message: error,
113+
cause: ctx.req.query("error_description")
114+
})
115+
116+
if (!code) return responseErrorObject(ctx, { message: "No code provided" }, 400)
117+
118+
const exchanged = await client.exchange(code, callbackUrl)
119+
120+
if (exchanged.err) return ctx.json(exchanged.err, 400)
121+
122+
logger.debug("Exchanged tokens", exchanged.tokens)
123+
124+
setTokens(ctx, exchanged.tokens)
125+
126+
return ctx.redirect(next)
127+
})
128+
129+
export function setTokens(ctx: Context, tokens: Tokens) {
130+
setCookie(ctx, "access_token", tokens.access, {
131+
httpOnly: true,
132+
sameSite: "lax",
133+
path: "/",
134+
maxAge: tokens.expiresIn
135+
})
136+
setCookie(ctx, "refresh_token", tokens.refresh, {
137+
httpOnly: true,
138+
sameSite: "lax",
139+
path: "/",
140+
maxAge: 60 * 60 * 24 * 400 // 400 days
141+
})
142+
}
143+
144+
export default app
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* Configure OpenAuth to use unstorage as a store.
3+
*
4+
* This enables you to use any unstorage driver as a store.
5+
* Please refer to [unstorage docs](https://unstorage.unjs.io/drivers) for details on possible drivers.
6+
*
7+
* By default, it uses the memory driver.
8+
*
9+
* :::caution
10+
* The default memory driver is not meant to be used in production.
11+
* :::
12+
*
13+
* ```ts
14+
* import { UnStorage } from "@openauthjs/openauth/storage/unstorage"
15+
*
16+
* const storage = UnStorage()
17+
*
18+
* export default issuer({
19+
* storage,
20+
* // ...
21+
* })
22+
* ```
23+
*
24+
* Optionally, you can specify a driver.
25+
*
26+
* ```ts
27+
* import fsDriver from "unstorage/drivers/fs";
28+
*
29+
* UnStorage({
30+
* driver: fsDriver({ base: "./tmp" }),
31+
* })
32+
* ```
33+
*
34+
* @packageDocumentation
35+
*/
36+
import { joinKey, splitKey, StorageAdapter } from "@openauthjs/openauth/storage/storage"
37+
import { createStorage, type Driver as UnstorageDriver } from "unstorage"
38+
39+
//eslint-disable-next-line @typescript-eslint/no-explicit-any
40+
type Entry = { value: Record<string, any> | undefined; expiry?: number }
41+
42+
export function UnStorage({ driver }: { driver?: UnstorageDriver } = {}): StorageAdapter {
43+
const store = createStorage<Entry>({
44+
driver: driver
45+
})
46+
47+
return {
48+
async get(key: string[]) {
49+
const k = joinKey(key)
50+
const entry = await store.getItem(k)
51+
52+
if (!entry) {
53+
return undefined
54+
}
55+
56+
if (entry.expiry && Date.now() >= entry.expiry) {
57+
await store.removeItem(k)
58+
return undefined
59+
}
60+
61+
return entry.value
62+
},
63+
64+
//eslint-disable-next-line @typescript-eslint/no-explicit-any
65+
async set(key: string[], value: any, expiry?: Date) {
66+
const k = joinKey(key)
67+
68+
await store.setItem(k, {
69+
value,
70+
expiry: expiry ? expiry.getTime() : undefined
71+
} satisfies Entry)
72+
},
73+
74+
async remove(key: string[]) {
75+
const k = joinKey(key)
76+
await store.removeItem(k)
77+
},
78+
79+
async *scan(prefix: string[]) {
80+
const now = Date.now()
81+
const prefixStr = joinKey(prefix)
82+
83+
// Get all keys matching our prefix
84+
const keys = await store.getKeys(prefixStr)
85+
86+
for (const key of keys) {
87+
// Get the entry for this key
88+
const entry = await store.getItem(key)
89+
90+
if (!entry) continue
91+
if (entry.expiry && now >= entry.expiry) {
92+
// Clean up expired entries as we go
93+
await store.removeItem(key)
94+
continue
95+
}
96+
97+
yield [splitKey(key), entry.value]
98+
}
99+
}
100+
}
101+
}

0 commit comments

Comments
 (0)