Skip to content

Commit 3d7549d

Browse files
committed
decouples token extraction and authentication
1 parent 508d258 commit 3d7549d

File tree

4 files changed

+179
-124
lines changed

4 files changed

+179
-124
lines changed

core/src/app.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { routes as healthRoutes } from './services/health-service'
1010
import { logger, loggerMiddlewares } from '@onecore/utilities'
1111
import { koaSwagger } from 'koa2-swagger-ui'
1212
import { routes as swagggerRoutes } from './services/swagger'
13+
import { extractToken } from './middlewares/extract-token'
1314
import { requireAuth, requireRole } from './middlewares/keycloak-auth'
1415

1516
const app = new Koa()
@@ -52,7 +53,10 @@ healthRoutes(publicRouter)
5253
swagggerRoutes(publicRouter)
5354
app.use(publicRouter.routes())
5455

55-
// Unified authentication (cookie + Basic Auth)
56+
// Token extraction (cookie -> Bearer -> Basic Auth)
57+
app.use(extractToken)
58+
59+
// Authentication — verifies the extracted token
5660
app.use(requireAuth)
5761

5862
// Role-based authorization
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { Context, Next } from 'koa'
2+
import axios from 'axios'
3+
import auth from '../services/auth-service/keycloak'
4+
import config from '../common/config'
5+
import { logger } from '@onecore/utilities'
6+
7+
/**
8+
* Exchange Basic Auth credentials with Keycloak using client_credentials grant.
9+
* Takes the raw base64-encoded credentials from the Basic header.
10+
* Returns the access_token JWT string, or undefined on failure.
11+
*/
12+
async function exchangeBasicForToken(
13+
ctx: Context,
14+
base64Credentials: string
15+
): Promise<string | undefined> {
16+
const credentialsString = Buffer.from(base64Credentials, 'base64').toString(
17+
'utf-8'
18+
)
19+
const separatorIndex = credentialsString.indexOf(':')
20+
21+
if (separatorIndex === -1) {
22+
ctx.status = 401
23+
ctx.set('WWW-Authenticate', `Basic realm="${config.auth.keycloak.realm}"`)
24+
ctx.body = { message: 'Invalid Basic Auth format' }
25+
return undefined
26+
}
27+
28+
const clientId = credentialsString.slice(0, separatorIndex)
29+
const clientSecret = credentialsString.slice(separatorIndex + 1)
30+
31+
if (!clientId || !clientSecret) {
32+
ctx.status = 401
33+
ctx.set('WWW-Authenticate', `Basic realm="${config.auth.keycloak.realm}"`)
34+
ctx.body = { message: 'Missing client credentials' }
35+
return undefined
36+
}
37+
38+
const tokenEndpoint = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/token`
39+
40+
try {
41+
const params = new URLSearchParams({
42+
grant_type: 'client_credentials',
43+
client_id: clientId,
44+
client_secret: clientSecret,
45+
}).toString()
46+
47+
const response = await axios.post(tokenEndpoint, params, {
48+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
49+
timeout: 10000,
50+
})
51+
52+
const accessToken = response.data.access_token
53+
if (!accessToken) {
54+
logger.error('Keycloak returned success but no access_token')
55+
ctx.status = 401
56+
ctx.body = { message: 'Invalid credentials' }
57+
return undefined
58+
}
59+
60+
return accessToken
61+
} catch (err) {
62+
logger.error(
63+
{ err, clientId },
64+
'Service account authentication failed — Keycloak rejected credentials'
65+
)
66+
ctx.status = 401
67+
ctx.set('WWW-Authenticate', `Basic realm="${config.auth.keycloak.realm}"`)
68+
ctx.body = { message: 'Invalid credentials' }
69+
return undefined
70+
}
71+
}
72+
73+
// --- Token extraction middlewares ---
74+
// Each one tries to set ctx.state.accessToken from its source,
75+
// then delegates to the next extractor if it didn't find anything.
76+
77+
async function extractCookieToken(ctx: Context, next: Next) {
78+
let token = ctx.cookies.get('auth_token')
79+
if (!token) return next()
80+
81+
// Proactive token refresh
82+
const refreshToken = ctx.cookies.get('refresh_token')
83+
if (refreshToken && auth.isTokenExpiringSoon(token, 60)) {
84+
try {
85+
const newTokens = await auth.refreshAccessToken(refreshToken)
86+
auth.tokenService.setCookies(ctx, newTokens)
87+
token = newTokens.access_token
88+
} catch (refreshError) {
89+
logger.error(
90+
refreshError,
91+
'Token refresh failed, falling back to existing token'
92+
)
93+
}
94+
}
95+
96+
ctx.state.accessToken = token
97+
await next()
98+
}
99+
100+
async function extractBasicAuthToken(ctx: Context, next: Next) {
101+
if (ctx.state.accessToken) return next()
102+
103+
const authHeader = ctx.get('Authorization')
104+
if (!authHeader?.startsWith('Basic ')) return next()
105+
106+
const token = await exchangeBasicForToken(
107+
ctx,
108+
authHeader.slice('Basic '.length)
109+
)
110+
if (token) {
111+
ctx.state.accessToken = token
112+
}
113+
await next()
114+
}
115+
116+
async function extractBearerToken(ctx: Context, next: Next) {
117+
if (ctx.state.accessToken) return next()
118+
119+
const authHeader = ctx.get('Authorization')
120+
if (authHeader?.startsWith('Bearer ')) {
121+
ctx.state.accessToken = authHeader.slice('Bearer '.length)
122+
}
123+
await next()
124+
}
125+
126+
/**
127+
* Middleware that tries to extract a token from multiple sources (in order):
128+
* 1. Cookie (auth_token)
129+
* 2. Basic Auth (exchanged for Keycloak token)
130+
* 3. Bearer header
131+
*
132+
* Sets ctx.state.accessToken from the first matching source.
133+
* Token verification (Keycloak JWKS / legacy JWT) happens in requireAuth.
134+
*/
135+
export const extractToken = async (ctx: Context, next: Next) => {
136+
await extractCookieToken(ctx, async () => {
137+
await extractBasicAuthToken(ctx, async () => {
138+
await extractBearerToken(ctx, next)
139+
})
140+
})
141+
}

core/src/middlewares/keycloak-auth.ts

Lines changed: 31 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -1,138 +1,25 @@
1+
import assert from 'node:assert'
12
import { Context, Next } from 'koa'
2-
import axios from 'axios'
33
import jwt from 'jsonwebtoken'
44
import auth from '../services/auth-service/keycloak'
55
import config from '../common/config'
66
import { logger } from '@onecore/utilities'
77

88
/**
9-
* Exchange Basic Auth credentials with Keycloak using client_credentials grant.
10-
* Returns the access_token JWT string, or null if the exchange fails
11-
* (in which case ctx.status and ctx.body are already set).
9+
* Middleware to protect routes. Must run after extractToken.
10+
* Verifies the token (Keycloak JWKS, with legacy JWT fallback) and sets ctx.state.user.
1211
*/
13-
async function exchangeBasicForToken(
14-
ctx: Context
15-
): Promise<string | undefined> {
16-
const authHeader = ctx.get('Authorization')
17-
if (!authHeader?.startsWith('Basic ')) return undefined
18-
19-
const base64Credentials = authHeader.slice('Basic '.length)
20-
const credentialsString = Buffer.from(base64Credentials, 'base64').toString(
21-
'utf-8'
22-
)
23-
const separatorIndex = credentialsString.indexOf(':')
24-
25-
if (separatorIndex === -1) {
26-
ctx.status = 401
27-
ctx.set('WWW-Authenticate', `Basic realm="${config.auth.keycloak.realm}"`)
28-
ctx.body = { message: 'Invalid Basic Auth format' }
29-
return undefined
30-
}
31-
32-
const clientId = credentialsString.slice(0, separatorIndex)
33-
const clientSecret = credentialsString.slice(separatorIndex + 1)
34-
35-
if (!clientId || !clientSecret) {
36-
ctx.status = 401
37-
ctx.set('WWW-Authenticate', `Basic realm="${config.auth.keycloak.realm}"`)
38-
ctx.body = { message: 'Missing client credentials' }
39-
return undefined
40-
}
41-
42-
const tokenEndpoint = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/token`
43-
44-
try {
45-
const params = new URLSearchParams({
46-
grant_type: 'client_credentials',
47-
client_id: clientId,
48-
client_secret: clientSecret,
49-
}).toString()
50-
51-
const response = await axios.post(tokenEndpoint, params, {
52-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
53-
timeout: 10000,
54-
})
55-
56-
const accessToken = response.data.access_token
57-
if (!accessToken) {
58-
logger.error('Keycloak returned success but no access_token')
59-
ctx.status = 401
60-
ctx.body = { message: 'Invalid credentials' }
61-
return undefined
62-
}
12+
export const requireAuth = async (ctx: Context, next: Next) => {
13+
const accessToken: string | undefined = ctx.state.accessToken
6314

64-
return accessToken
65-
} catch (err) {
66-
logger.error(
67-
{ err, clientId },
68-
'Service account authentication failed — Keycloak rejected credentials'
69-
)
15+
if (!accessToken) {
7016
ctx.status = 401
71-
ctx.set('WWW-Authenticate', `Basic realm="${config.auth.keycloak.realm}"`)
72-
ctx.body = { message: 'Invalid credentials' }
73-
return undefined
74-
}
75-
}
76-
77-
/**
78-
* Try legacy Bearer JWT authentication (tokens from /auth/generatetoken).
79-
* Returns true if the request was authenticated, false otherwise.
80-
*/
81-
async function tryLegacyBearerAuth(ctx: Context, next: Next): Promise<boolean> {
82-
const authHeader = ctx.get('Authorization')
83-
if (!authHeader?.startsWith('Bearer ')) return false
84-
85-
const token = authHeader.slice('Bearer '.length)
86-
const decoded = jwt.verify(token, config.auth.secret) as {
87-
sub: string
88-
username: string
89-
}
90-
ctx.state.user = {
91-
id: decoded.sub,
92-
username: decoded.username,
93-
source: 'legacy-jwt',
94-
// REMOVE WHEN INTERNAL PORTAL USES KEYCLOAK
95-
realm_access: { roles: ['api-access'] },
96-
//TODO: Fix auth in internal portal to use keycloak! we cannot support roles in legacy tokens without a major refactor, so we just give them api-access for now
17+
ctx.body = { message: 'Authentication required' }
18+
return
9719
}
98-
await next()
99-
return true
100-
}
10120

102-
// Middleware to protect routes with proactive token refresh, Basic Auth, and legacy Bearer JWT support
103-
export const requireAuth = async (ctx: Context, next: Next) => {
10421
try {
105-
let accessToken =
106-
ctx.cookies.get('auth_token') ?? (await exchangeBasicForToken(ctx))
107-
108-
if (!accessToken) {
109-
if (await tryLegacyBearerAuth(ctx, next)) return
110-
111-
if (ctx.status !== 401) {
112-
ctx.status = 401
113-
ctx.body = { message: 'Authentication required' }
114-
}
115-
return
116-
}
117-
118-
// Proactive token refresh (cookie path only)
119-
const refreshToken = ctx.cookies.get('refresh_token')
120-
if (refreshToken && auth.isTokenExpiringSoon(accessToken, 60)) {
121-
try {
122-
const newTokens = await auth.refreshAccessToken(refreshToken)
123-
auth.tokenService.setCookies(ctx, newTokens)
124-
accessToken = newTokens.access_token
125-
} catch (refreshError) {
126-
logger.error(
127-
refreshError,
128-
'Token refresh failed, falling back to existing token'
129-
)
130-
}
131-
}
132-
133-
// Single verification path for all token sources
13422
const verifiedToken = await auth.jwksService.verifyToken(accessToken)
135-
13623
ctx.state.user = {
13724
id: verifiedToken.sub,
13825
email: verifiedToken.email,
@@ -141,7 +28,24 @@ export const requireAuth = async (ctx: Context, next: Next) => {
14128
source: 'keycloak',
14229
realm_access: verifiedToken.realm_access,
14330
}
31+
return next()
32+
} catch {
33+
// Not a Keycloak token — try legacy JWT. TODO: Remove legacy JWT use in the codebase
34+
}
14435

36+
try {
37+
const decoded = jwt.verify(accessToken, config.auth.secret) as {
38+
sub: string
39+
username: string
40+
}
41+
ctx.state.user = {
42+
id: decoded.sub,
43+
username: decoded.username,
44+
source: 'legacy-jwt',
45+
// REMOVE WHEN INTERNAL PORTAL USES KEYCLOAK
46+
realm_access: { roles: ['api-access'] },
47+
//TODO: Fix auth in internal portal to use keycloak! we cannot support roles in legacy tokens without a major refactor, so we just give them api-access for now
48+
}
14549
return next()
14650
} catch (error) {
14751
logger.error(error, 'Authentication error:')
@@ -151,11 +55,16 @@ export const requireAuth = async (ctx: Context, next: Next) => {
15155
}
15256

15357
// Middleware to check for specific Keycloak realm roles.
154-
// Must run after requireAuth (ctx.state.user must already be set).
58+
// Must run after requireAuth.
15559
export const requireRole = (requiredRoles: string | string[]) => {
15660
const roles = Array.isArray(requiredRoles) ? requiredRoles : [requiredRoles]
15761

15862
return async (ctx: Context, next: Next) => {
63+
assert(
64+
ctx.state.user,
65+
'requireRole middleware must run after requireAuth — ctx.state.user is not set'
66+
)
67+
15968
try {
16069
const userRoles: string[] = ctx.state.user?.realm_access?.roles || []
16170
const hasRequiredRole = roles.some((role) => userRoles.includes(role))

core/src/services/auth-service/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { logger } from '@onecore/utilities'
55
import hash from './hash'
66
import { createToken } from './jwt'
77
import auth from './keycloak'
8+
import { extractToken } from '../../middlewares/extract-token'
89
import { requireAuth } from '../../middlewares/keycloak-auth'
910

1011
/**
@@ -235,7 +236,7 @@ export const routes = (router: KoaRouter) => {
235236
* '401':
236237
* description: Unauthorized
237238
*/
238-
router.get('(.*)/auth/profile', requireAuth, async (ctx) => {
239+
router.get('(.*)/auth/profile', extractToken, requireAuth, async (ctx) => {
239240
ctx.body = ctx.state.user
240241
})
241242

0 commit comments

Comments
 (0)