1+ import assert from 'node:assert'
12import { Context , Next } from 'koa'
2- import axios from 'axios'
33import jwt from 'jsonwebtoken'
44import auth from '../services/auth-service/keycloak'
55import config from '../common/config'
66import { 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.
15559export 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 ) )
0 commit comments