Skip to content

Commit d8e5666

Browse files
committed
prettier
1 parent 86eda32 commit d8e5666

File tree

1 file changed

+124
-124
lines changed

1 file changed

+124
-124
lines changed
Lines changed: 124 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -1,124 +1,124 @@
1-
import { Context, Next } from 'koa'
2-
import axios from 'axios'
3-
import * as jose from 'jose'
4-
import config from '../common/config'
5-
import { logger } from '@onecore/utilities'
6-
7-
/**
8-
* Route prefixes accessible via service account (Basic Auth → Keycloak).
9-
* These routes bypass the global JWT middleware and body parser in app.ts.
10-
*/
11-
const serviceAccountRoutes: string[] = ['/scan-receipt']
12-
13-
export const isServiceAccountRoute = (path: string): boolean =>
14-
serviceAccountRoutes.some((prefix) => path.startsWith(prefix))
15-
16-
/**
17-
* Middleware that authenticates via Basic Auth → Keycloak client_credentials grant.
18-
*
19-
* The caller sends `Authorization: Basic <base64(client_id:client_secret)>`.
20-
* This middleware exchanges those credentials with Keycloak's token endpoint
21-
* using the client_credentials grant type. If Keycloak accepts the credentials,
22-
* the returned access token is decoded and (optionally) checked for a required role.
23-
*/
24-
export const requireServiceAccountAuth = (requiredRole?: string) => {
25-
return async (ctx: Context, next: Next) => {
26-
try {
27-
const authHeader = ctx.get('Authorization')
28-
29-
if (!authHeader || !authHeader.startsWith('Basic ')) {
30-
ctx.status = 401
31-
ctx.set('WWW-Authenticate', 'Basic realm="onecore"')
32-
ctx.body = { message: 'Authentication required' }
33-
return
34-
}
35-
36-
const base64Credentials = authHeader.slice('Basic '.length)
37-
const credentialsString = Buffer.from(
38-
base64Credentials,
39-
'base64'
40-
).toString('utf-8')
41-
const separatorIndex = credentialsString.indexOf(':')
42-
43-
if (separatorIndex === -1) {
44-
ctx.status = 401
45-
ctx.body = { message: 'Invalid Basic Auth format' }
46-
return
47-
}
48-
49-
const clientId = credentialsString.slice(0, separatorIndex)
50-
const clientSecret = credentialsString.slice(separatorIndex + 1)
51-
52-
if (!clientId || !clientSecret) {
53-
ctx.status = 401
54-
ctx.body = { message: 'Missing client credentials' }
55-
return
56-
}
57-
58-
const tokenEndpoint = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/token`
59-
60-
let accessToken: string
61-
try {
62-
const params = new URLSearchParams({
63-
grant_type: 'client_credentials',
64-
client_id: clientId,
65-
client_secret: clientSecret,
66-
}).toString()
67-
68-
const response = await axios.post(tokenEndpoint, params, {
69-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
70-
timeout: 10000,
71-
})
72-
73-
accessToken = response.data.access_token
74-
} catch (err) {
75-
logger.error(
76-
{ err, clientId },
77-
'Service account authentication failed — Keycloak rejected credentials'
78-
)
79-
ctx.status = 401
80-
ctx.body = { message: 'Invalid credentials' }
81-
return
82-
}
83-
84-
if (!accessToken) {
85-
logger.error('Keycloak returned success but no access_token')
86-
ctx.status = 401
87-
ctx.body = { message: 'Invalid credentials' }
88-
return
89-
}
90-
91-
const decoded = jose.decodeJwt(accessToken)
92-
93-
if (requiredRole) {
94-
const realmAccess = decoded.realm_access as
95-
| { roles?: string[] }
96-
| undefined
97-
const roles = realmAccess?.roles || []
98-
99-
if (!roles.includes(requiredRole)) {
100-
logger.warn(
101-
{ clientId, requiredRole, roles },
102-
'Service account missing required role'
103-
)
104-
ctx.status = 403
105-
ctx.body = { message: 'Insufficient permissions' }
106-
return
107-
}
108-
}
109-
110-
ctx.state.user = {
111-
id: decoded.sub,
112-
preferred_username: decoded.preferred_username,
113-
source: 'service-account',
114-
realm_access: decoded.realm_access,
115-
}
116-
117-
return next()
118-
} catch (err) {
119-
logger.error(err, 'Unexpected error in service account auth middleware')
120-
ctx.status = 401
121-
ctx.body = { message: 'Authentication failed' }
122-
}
123-
}
124-
}
1+
import { Context, Next } from 'koa'
2+
import axios from 'axios'
3+
import * as jose from 'jose'
4+
import config from '../common/config'
5+
import { logger } from '@onecore/utilities'
6+
7+
/**
8+
* Route prefixes accessible via service account (Basic Auth → Keycloak).
9+
* These routes bypass the global JWT middleware and body parser in app.ts.
10+
*/
11+
const serviceAccountRoutes: string[] = ['/scan-receipt']
12+
13+
export const isServiceAccountRoute = (path: string): boolean =>
14+
serviceAccountRoutes.some((prefix) => path.startsWith(prefix))
15+
16+
/**
17+
* Middleware that authenticates via Basic Auth → Keycloak client_credentials grant.
18+
*
19+
* The caller sends `Authorization: Basic <base64(client_id:client_secret)>`.
20+
* This middleware exchanges those credentials with Keycloak's token endpoint
21+
* using the client_credentials grant type. If Keycloak accepts the credentials,
22+
* the returned access token is decoded and (optionally) checked for a required role.
23+
*/
24+
export const requireServiceAccountAuth = (requiredRole?: string) => {
25+
return async (ctx: Context, next: Next) => {
26+
try {
27+
const authHeader = ctx.get('Authorization')
28+
29+
if (!authHeader || !authHeader.startsWith('Basic ')) {
30+
ctx.status = 401
31+
ctx.set('WWW-Authenticate', 'Basic realm="onecore"')
32+
ctx.body = { message: 'Authentication required' }
33+
return
34+
}
35+
36+
const base64Credentials = authHeader.slice('Basic '.length)
37+
const credentialsString = Buffer.from(
38+
base64Credentials,
39+
'base64'
40+
).toString('utf-8')
41+
const separatorIndex = credentialsString.indexOf(':')
42+
43+
if (separatorIndex === -1) {
44+
ctx.status = 401
45+
ctx.body = { message: 'Invalid Basic Auth format' }
46+
return
47+
}
48+
49+
const clientId = credentialsString.slice(0, separatorIndex)
50+
const clientSecret = credentialsString.slice(separatorIndex + 1)
51+
52+
if (!clientId || !clientSecret) {
53+
ctx.status = 401
54+
ctx.body = { message: 'Missing client credentials' }
55+
return
56+
}
57+
58+
const tokenEndpoint = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/token`
59+
60+
let accessToken: string
61+
try {
62+
const params = new URLSearchParams({
63+
grant_type: 'client_credentials',
64+
client_id: clientId,
65+
client_secret: clientSecret,
66+
}).toString()
67+
68+
const response = await axios.post(tokenEndpoint, params, {
69+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
70+
timeout: 10000,
71+
})
72+
73+
accessToken = response.data.access_token
74+
} catch (err) {
75+
logger.error(
76+
{ err, clientId },
77+
'Service account authentication failed — Keycloak rejected credentials'
78+
)
79+
ctx.status = 401
80+
ctx.body = { message: 'Invalid credentials' }
81+
return
82+
}
83+
84+
if (!accessToken) {
85+
logger.error('Keycloak returned success but no access_token')
86+
ctx.status = 401
87+
ctx.body = { message: 'Invalid credentials' }
88+
return
89+
}
90+
91+
const decoded = jose.decodeJwt(accessToken)
92+
93+
if (requiredRole) {
94+
const realmAccess = decoded.realm_access as
95+
| { roles?: string[] }
96+
| undefined
97+
const roles = realmAccess?.roles || []
98+
99+
if (!roles.includes(requiredRole)) {
100+
logger.warn(
101+
{ clientId, requiredRole, roles },
102+
'Service account missing required role'
103+
)
104+
ctx.status = 403
105+
ctx.body = { message: 'Insufficient permissions' }
106+
return
107+
}
108+
}
109+
110+
ctx.state.user = {
111+
id: decoded.sub,
112+
preferred_username: decoded.preferred_username,
113+
source: 'service-account',
114+
realm_access: decoded.realm_access,
115+
}
116+
117+
return next()
118+
} catch (err) {
119+
logger.error(err, 'Unexpected error in service account auth middleware')
120+
ctx.status = 401
121+
ctx.body = { message: 'Authentication failed' }
122+
}
123+
}
124+
}

0 commit comments

Comments
 (0)