Skip to content

Commit

Permalink
fix: Improve withApiKey auth middleware (#226)
Browse files Browse the repository at this point in the history
Co-authored-by: Seam Bot <[email protected]>
  • Loading branch information
andrii-balitskyi and seambot authored Dec 3, 2024
1 parent 8476d35 commit 3b4a7ee
Show file tree
Hide file tree
Showing 4 changed files with 127 additions and 13 deletions.
57 changes: 45 additions & 12 deletions src/lib/middleware/with-api-key.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import jwt from "jsonwebtoken"
import { type Middleware, UnauthorizedException } from "nextlove"
import type { AuthenticatedRequest } from "src/types/authenticated-request.ts"

Expand All @@ -13,30 +14,62 @@ export const withApiKey: Middleware<
db: Database
}
> = (next) => async (req, res) => {
if (req.db == null) {
return res
.status(500)
.end(
"The withApiKey middleware requires req.db. Use it with the withDb middleware.",
)
if (req.headers.authorization == null) {
throw new UnauthorizedException({
type: "unauthorized",
message: "No Authorization header",
})
}

const token = req.headers.authorization?.split("Bearer ")?.[1]
if (token == null) return res.status(401).end("Unauthorized")
if (token == null) {
throw new UnauthorizedException({
type: "unauthorized",
message: "No token provided",
})
}

// TODO: Validate authorization.
// If relevant, add the user or the decoded JWT to the request on req.auth.
if (token.startsWith("seam_cst1")) {
throw new UnauthorizedException({
type: "client_session_token_used_for_api_key",
message: "A client session token was used instead of an API key",
})
}

if (token.startsWith("seam_at")) {
throw new UnauthorizedException({
type: "access_token_used_for_api_key",
message: "An access token was used instead of an API key",
})
}

let decodedJwt
try {
decodedJwt = jwt.decode(token)
} catch {}
if (decodedJwt != null) {
throw new UnauthorizedException({
type: "unauthorized",
message: "A JWT was used instead of an API key",
})
}

const api_key = req.db.api_keys.find((key) => key.token === token)

if (api_key == null) {
throw new UnauthorizedException({
type: "invalid_api_key",
message: "Invalid API Key (not found)",
type: "unauthorized",
message: "API Key not found",
})
}

req.auth = { type: "api_key", workspace_id: api_key.workspace_id }
req.auth = {
type: "api_key",
api_key_id: api_key.api_key_id,
api_key_short_token: api_key.short_token,
token,
workspace_id: api_key.workspace_id,
}

// Cannot run middleware after auth middleware.
// UPSTREAM: https://github.com/seamapi/nextlove/issues/118
Expand Down
2 changes: 1 addition & 1 deletion src/pages/api/devices/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const common_params = z.object({
})

export default withRouteSpec({
auth: ["api_key", "console_session", "client_session"],
auth: ["console_session", "client_session", "api_key"],
methods: ["GET", "POST"],
commonParams: common_params,
jsonResponse: z.object({
Expand Down
3 changes: 3 additions & 0 deletions src/types/authenticated-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ export type AuthenticatedRequest = Request & {
auth:
| {
type: "api_key"
api_key_id: string
api_key_short_token: string
workspace_id: string
token: string
}
| {
type: "access_token"
Expand Down
78 changes: 78 additions & 0 deletions test/middleware/with-api-key.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import test from "ava"
import jwt from "jsonwebtoken"

import {
getTestServer,
type SimpleAxiosError,
} from "fixtures/get-test-server.ts"
import { seedDatabase } from "lib/database/seed.ts"

test("withApiKey middleware - successful auth", async (t) => {
const { axios, db } = await getTestServer(t, { seed: false })
const seed_result = seedDatabase(db)

// Test successful auth with API key
const { status } = await axios.get("/devices/list", {
headers: {
Authorization: `Bearer ${seed_result.seam_apikey1_token}`,
},
})
t.is(status, 200)

// Test missing Authorization header
const missingAuthErr = await t.throwsAsync<SimpleAxiosError>(
axios.get("/devices/list"),
)

t.is(missingAuthErr?.status, 401)
t.is(missingAuthErr?.response, "Unauthorized")

// Test invalid API key
const invalidKeyErr = await t.throwsAsync<SimpleAxiosError>(
axios.get("/devices/list", {
headers: {
Authorization: "Bearer invalid_api_key",
},
}),
)

t.is(invalidKeyErr?.status, 401)
t.is(invalidKeyErr?.response.error.type, "unauthorized")

// Test using client session token instead of API key
const clientSessionErr = await t.throwsAsync<SimpleAxiosError>(
axios.get("/devices/list", {
headers: {
Authorization: `Bearer seam_cst1_123`,
},
}),
)
t.is(clientSessionErr?.status, 401)
t.is(
clientSessionErr?.response.error.type,
"client_session_token_used_for_api_key",
)

// Test using access token instead of API key
const accessTokenErr = await t.throwsAsync<SimpleAxiosError>(
axios.get("/devices/list", {
headers: {
Authorization: `Bearer seam_at_123`,
},
}),
)
t.is(accessTokenErr?.status, 401)
t.is(accessTokenErr?.response.error.type, "access_token_used_for_api_key")

// Test using JWT instead of API key
const token = jwt.sign({ some: "payload" }, "secret")
const jwtErr = await t.throwsAsync<SimpleAxiosError>(
axios.get("/devices/list", {
headers: {
Authorization: `Bearer ${token}`,
},
}),
)
t.is(jwtErr?.status, 401)
t.is(jwtErr?.response.error.type, "unauthorized")
})

0 comments on commit 3b4a7ee

Please sign in to comment.