Skip to content

Commit 2319254

Browse files
committed
refactored auth and fixed bugs
1 parent a0aac60 commit 2319254

File tree

2 files changed

+135
-64
lines changed

2 files changed

+135
-64
lines changed

Dockerfile

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,6 @@ RUN if [ -f web/package.json ]; then \
1818
COPY tsconfig.json ./
1919
COPY src ./src
2020

21-
# Note: web/dist is created during the build step above (line 12-14)
22-
# No need to copy it from host - it's built inside the container
23-
2421
# App listens on 3000
2522
EXPOSE 3000
2623

src/lib/auth.ts

Lines changed: 135 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -15,51 +15,97 @@ export interface AuthResult {
1515
expiresAt: number;
1616
}
1717

18-
// Bearer auth supporting either introspection (opaque tokens) or JWT validation (JWKS)
19-
export async function verifyAccessToken(token: string, config: AuthConfig, expectedResource?: string): Promise<AuthResult> {
20-
if (!config.DISABLE_AUTH) return { clientId: "dev", scopes: [], expiresAt: Math.floor(Date.now() / 1000) + 3600 };
18+
async function verifyJwtToken(
19+
token: string,
20+
config: AuthConfig,
21+
expectedResource?: string
22+
): Promise<AuthResult> {
23+
if (!config.JWT_JWKS_URL) {
24+
throw new Error("JWT_JWKS_URL or JWT_ISSUER required for JWT mode");
25+
}
2126

22-
if (config.AUTH_TOKEN_MODE === "jwt") {
23-
if (!config.JWT_JWKS_URL) throw new Error("JWT_JWKS_URL or JWT_ISSUER required for JWT mode");
24-
const JWKS = createRemoteJWKSet(new URL(config.JWT_JWKS_URL));
25-
const { payload } = await jwtVerify(token, JWKS, {
26-
issuer: config.JWT_ISSUER,
27-
audience: config.JWT_AUDIENCE,
28-
algorithms: ["RS256"]
29-
});
30-
31-
const scopes = parseScopes(payload);
32-
const exp = typeof payload.exp === "number" ? payload.exp : Math.floor(Date.now() / 1000) + 3600;
33-
if (expectedResource && payload.aud && typeof payload.aud === "string" && payload.aud !== expectedResource) {
34-
console.warn(`[auth] audience mismatch: token.aud="${payload.aud}" expected="${expectedResource}"`);
35-
throw new Error("Token not intended for this resource");
36-
}
37-
return {
38-
clientId: (payload.client_id as string) || (payload.sub as string) || "unknown",
39-
scopes,
40-
expiresAt: exp,
41-
};
42-
} else {
43-
// Use external introspection (opaque tokens)
44-
const res = await fetch(config.OAUTH_INTROSPECT_URL, {
45-
method: "POST",
46-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
47-
body: new URLSearchParams({ token }).toString()
48-
});
49-
if (!res.ok) throw new Error(`Introspection failed: ${res.status}`);
50-
const data = await res.json();
51-
if (!data.active) throw new Error("Token inactive");
52-
53-
if (expectedResource && data.aud && data.aud !== expectedResource) {
54-
console.warn(`[auth] audience mismatch: token.aud="${data.aud}" expected="${expectedResource}"`);
27+
const JWKS = createRemoteJWKSet(new URL(config.JWT_JWKS_URL));
28+
const { payload } = await jwtVerify(token, JWKS, {
29+
issuer: config.JWT_ISSUER,
30+
audience: config.JWT_AUDIENCE,
31+
algorithms: ["RS256"]
32+
});
33+
34+
const scopes = parseScopes(payload);
35+
36+
if (typeof payload.exp !== "number") {
37+
throw new Error("Token missing required exp claim");
38+
}
39+
const expiresAt = payload.exp;
40+
41+
if (expectedResource && payload.aud) {
42+
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
43+
if (!audiences.includes(expectedResource)) {
44+
console.warn(`[auth] audience mismatch: token.aud="${JSON.stringify(payload.aud)}" expected="${expectedResource}"`);
5545
throw new Error("Token not intended for this resource");
5646
}
57-
return {
58-
clientId: data.client_id ?? "unknown",
59-
scopes: (data.scope ? String(data.scope).split(" ") : []) as string[],
60-
expiresAt: typeof data.exp === "number" ? data.exp : Math.floor(Date.now() / 1000) + 3600
47+
}
48+
49+
return {
50+
clientId: (payload.client_id as string) || (payload.sub as string) || "unknown",
51+
scopes,
52+
expiresAt
53+
};
54+
}
55+
56+
async function verifyTokenViaIntrospection(
57+
token: string,
58+
config: AuthConfig,
59+
expectedResource?: string
60+
): Promise<AuthResult> {
61+
const response = await fetch(config.OAUTH_INTROSPECT_URL, {
62+
method: "POST",
63+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
64+
body: new URLSearchParams({ token }).toString()
65+
});
66+
67+
if (!response.ok) {
68+
throw new Error(`Introspection failed: ${response.status}`);
69+
}
70+
71+
const data = await response.json();
72+
if (!data.active) {
73+
throw new Error("Token inactive");
74+
}
75+
76+
if (expectedResource && data.aud && data.aud !== expectedResource) {
77+
console.warn(`[auth] audience mismatch: token.aud="${data.aud}" expected="${expectedResource}"`);
78+
throw new Error("Token not intended for this resource");
79+
}
80+
81+
return {
82+
clientId: data.client_id ?? "unknown",
83+
scopes: (data.scope ? String(data.scope).split(" ") : []) as string[],
84+
expiresAt: typeof data.exp === "number"
85+
? data.exp
86+
: Math.floor(Date.now() / 1000) + 3600
87+
};
88+
}
89+
90+
// Bearer auth supporting either introspection (opaque tokens) or JWT validation (JWKS)
91+
export async function verifyAccessToken(
92+
token: string,
93+
config: AuthConfig,
94+
expectedResource?: string
95+
): Promise<AuthResult> {
96+
if (config.DISABLE_AUTH) {
97+
return {
98+
clientId: "dev",
99+
scopes: [],
100+
expiresAt: Math.floor(Date.now() / 1000) + 3600
61101
};
62102
}
103+
104+
if (config.AUTH_TOKEN_MODE === "jwt") {
105+
return await verifyJwtToken(token, config, expectedResource);
106+
}
107+
108+
return await verifyTokenViaIntrospection(token, config, expectedResource);
63109
}
64110

65111
export function parseScopes(payload: JWTPayload): string[] {
@@ -68,6 +114,38 @@ export function parseScopes(payload: JWTPayload): string[] {
68114
return String(raw).split(" ").filter(Boolean);
69115
}
70116

117+
function extractBearerToken(authorizationHeader: string | undefined): string | null {
118+
if (!authorizationHeader) {
119+
return null;
120+
}
121+
122+
const [type, token] = authorizationHeader.split(" ");
123+
if (!token || type.toLowerCase() !== "bearer") {
124+
return null;
125+
}
126+
127+
return token;
128+
}
129+
130+
function isTokenExpired(authInfo: AuthResult): boolean {
131+
const currentTime = Math.floor(Date.now() / 1000);
132+
return authInfo.expiresAt < currentTime;
133+
}
134+
135+
function sendUnauthorizedResponse(
136+
res: any,
137+
resourceMetadataUrl: string,
138+
error: string,
139+
message?: string
140+
): void {
141+
res.set('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", scope="mcp:tools"`);
142+
const responseBody: any = { error };
143+
if (message) {
144+
responseBody.message = message;
145+
}
146+
res.status(401).json(responseBody);
147+
}
148+
71149
export function createAuthMiddleware(config: AuthConfig) {
72150
return async (req: any, res: any, next: any) => {
73151
if (config.DISABLE_AUTH) return next();
@@ -78,30 +156,26 @@ export function createAuthMiddleware(config: AuthConfig) {
78156
const resourceMetadataUrl = `${baseUrl}/.well-known/oauth-protected-resource`;
79157

80158
try {
81-
const auth = req.headers.authorization;
82-
if (!auth) {
83-
console.warn(`[auth] missing authorization for ${req.method} ${req.originalUrl}`);
84-
res.set('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", scope="mcp:tools"`);
85-
return res.status(401).json({ error: "missing_authorization" });
86-
}
87-
const [type, token] = auth.split(" ");
88-
if (!token || type.toLowerCase() !== "bearer") {
89-
console.warn(`[auth] invalid authorization header for ${req.method} ${req.originalUrl}: ${auth}`);
90-
res.set('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", scope="mcp:tools"`);
91-
return res.status(401).json({ error: "invalid_authorization" });
159+
const token = extractBearerToken(req.headers.authorization);
160+
if (!token) {
161+
console.warn(`[auth] missing or invalid authorization for ${req.method} ${req.originalUrl}`);
162+
sendUnauthorizedResponse(res, resourceMetadataUrl, "missing_authorization");
163+
return;
92164
}
93-
const info = await verifyAccessToken(token, config, expectedResource);
94-
if (info.expiresAt < Math.floor(Date.now() / 1000)) {
95-
console.warn(`[auth] token expired for client ${info.clientId}`);
96-
res.set('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", scope="mcp:tools"`);
97-
return res.status(401).json({ error: "token_expired" });
165+
166+
const authInfo = await verifyAccessToken(token, config, expectedResource);
167+
168+
if (isTokenExpired(authInfo)) {
169+
console.warn(`[auth] token expired for client ${authInfo.clientId}`);
170+
sendUnauthorizedResponse(res, resourceMetadataUrl, "token_expired");
171+
return;
98172
}
99-
req.auth = info;
173+
174+
req.auth = authInfo;
100175
next();
101-
} catch (e: any) {
102-
console.warn(`[auth] invalid_token on ${req.method} ${req.originalUrl}: ${e?.message || e}`);
103-
res.set('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", scope="mcp:tools"`);
104-
return res.status(401).json({ error: "invalid_token", message: String(e?.message ?? e) });
176+
} catch (error: any) {
177+
console.warn(`[auth] invalid_token on ${req.method} ${req.originalUrl}: ${error?.message || error}`);
178+
sendUnauthorizedResponse(res, resourceMetadataUrl, "invalid_token", String(error?.message ?? error));
105179
}
106180
};
107181
}

0 commit comments

Comments
 (0)