Skip to content

Commit e45e22e

Browse files
committed
Working on the middleware behavior a bit more
1 parent 9ce46b1 commit e45e22e

File tree

1 file changed

+82
-37
lines changed

1 file changed

+82
-37
lines changed

src/nextjs/index.ts

Lines changed: 82 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,16 @@ async function runMiddleware(options: RunMiddlewareOptions, request: NextRequest
1515
return [NextResponse.rewrite(newURL)];
1616
}
1717

18-
// TODO safari doesn't like "secure: true" if insecure, even when localhost, need to check emulators
19-
// replace tenant id, we should probably use different cookies for emulated and not
20-
// need to test in firefox
21-
const ID_TOKEN_COOKIE_NAME = `firebase:authUser:${options.apiKey}:[DEFAULT]`;
22-
const REFRESH_TOKEN_COOKIE_NAME = `${ID_TOKEN_COOKIE_NAME}:refreshToken`;
23-
const ID_TOKEN_COOKIE = { path: "/", secure: true, sameSite: "strict", partitioned: true, name: ID_TOKEN_COOKIE_NAME, maxAge: 34560000 } as const;
18+
const isDevMode = process.env.NODE_ENV === 'development';
19+
const userAgent = request.headers.get('User-Agent');
20+
const isSafari = userAgent?.includes('Safari') && !userAgent?.includes("Chrome");
21+
const secureCookies = isSafari ? !isDevMode : true; // Safari can't do secure cookies on localhost
22+
// Need two different cookie names as Chrome doesn't allow __HOST- on localhost
23+
const ID_TOKEN_COOKIE_NAME = isDevMode ? `__dev_FIREBASE_[DEFAULT]` : `__HOST-FIREBASE_[DEFAULT]`;
24+
const REFRESH_TOKEN_COOKIE_NAME = isDevMode ? '__dev_FIREBASEID_[DEFAULT]' : `__HOST-FIREBASEID_[DEFAULT]`;
25+
console.log({ isDevMode, isSafari, secureCookies, ID_TOKEN_COOKIE_NAME, REFRESH_TOKEN_COOKIE_NAME });
26+
// TODO max-age should be ttl
27+
const ID_TOKEN_COOKIE = { path: "/", secure: secureCookies, sameSite: "strict", partitioned: true, name: ID_TOKEN_COOKIE_NAME, maxAge: 34560000, priority: 'high' } as const;
2428
const REFRESH_TOKEN_COOKIE = { ...ID_TOKEN_COOKIE, httpOnly: true, name: REFRESH_TOKEN_COOKIE_NAME } as const;
2529

2630
if (request.nextUrl.pathname === '/__cookies__') {
@@ -50,6 +54,7 @@ async function runMiddleware(options: RunMiddlewareOptions, request: NextRequest
5054

5155
let body: ReadableStream<any>|string|null = request.body;
5256

57+
// TODO error if emulated and not hitting emulators
5358
const isTokenRequest = !!url.pathname.match(/^(\/securetoken\.googleapis\.com)?\/v1\/token/);
5459
const isSignInRequest = !!url.pathname.match(/^(\/identitytoolkit\.googleapis\.com)?\/v1\/accounts:signInWith/);
5560

@@ -78,72 +83,110 @@ async function runMiddleware(options: RunMiddlewareOptions, request: NextRequest
7883
}
7984
let refreshToken;
8085
let idToken;
86+
let maxAge;
8187
if (isSignInRequest) {
8288
refreshToken = json.refreshToken;
8389
idToken = json.idToken;
90+
maxAge = json.expiresIn;
8491
json.refreshToken = "REDACTED";
8592
} else {
8693
refreshToken = json.refresh_token;
8794
idToken = json.id_token;
95+
maxAge = json.expires_in;
8896
json.refresh_token = "REDACTED";
8997
}
9098

9199
const currentIdToken = request.cookies.get({ ...ID_TOKEN_COOKIE, value: "" })?.value;
92100
const nextResponse = NextResponse.json(json, { status, statusText });
93-
if (idToken && currentIdToken !== idToken) nextResponse.cookies.set({...ID_TOKEN_COOKIE, value: idToken });
101+
if (idToken && currentIdToken !== idToken) nextResponse.cookies.set({...ID_TOKEN_COOKIE, maxAge, value: idToken });
94102
if (refreshToken) nextResponse.cookies.set({...REFRESH_TOKEN_COOKIE, value: refreshToken });
95103
return [nextResponse];
96104
}
97105

98106
const logout = (): RunMiddlewareResponse => {
99107
const decorateNextResponse = (response: NextResponse) => {
100-
if (request.cookies.get({...ID_TOKEN_COOKIE, value: "" })) response.cookies.delete({...ID_TOKEN_COOKIE, maxAge: 0});
101-
if (request.cookies.get({...REFRESH_TOKEN_COOKIE, value: "" })) response.cookies.delete({...REFRESH_TOKEN_COOKIE, maxAge: 0});
108+
if (request.cookies.get(ID_TOKEN_COOKIE_NAME)) response.cookies.delete({...ID_TOKEN_COOKIE, maxAge: 0});
109+
if (request.cookies.get(REFRESH_TOKEN_COOKIE_NAME)) response.cookies.delete({...REFRESH_TOKEN_COOKIE, maxAge: 0});
102110
return response;
103111
}
104112
return [undefined, decorateNextResponse, undefined];
105113
}
106114

107-
let authIdToken = request.cookies.get({ ...ID_TOKEN_COOKIE, value: "" })?.value;
108-
if (!authIdToken) return logout();
115+
const authIdToken = request.cookies.get({ ...ID_TOKEN_COOKIE, value: "" })?.value;
116+
const refresh_token = request.cookies.get({...REFRESH_TOKEN_COOKIE, value: "" })?.value;
117+
118+
if (authIdToken === undefined && !refresh_token) {
119+
console.error("no authIdToken && no refresh token");
120+
return [undefined, it => it, undefined];
121+
}
109122

110-
let isEmulatedCredential = false;
111-
let [jwtHeader, jwtPayload] = authIdToken.split(".").slice(0,2).map(it => JSON.parse(atob(it))) as [JWTHeaderParameters, FirebaseJWTPayload];
112-
// TODO check the tenantId
113-
if (jwtHeader?.typ !== 'JWT' || jwtPayload?.iss !== `https://securetoken.google.com/${options.projectId}` || jwtPayload.aud !== options.projectId || jwtPayload.firebase?.tenant !== options.tenantId) {
114-
console.error("I hates the claims.", jwtHeader, jwtPayload);
123+
if (authIdToken === "") {
124+
console.error("logout sentinel detected");
115125
return logout();
116126
}
117-
if (jwtHeader?.alg === 'none') {
118-
isEmulatedCredential = true;
119-
} else if (jwtHeader?.alg !== 'RS256') {
120-
console.error("I hates the alg.", jwtHeader?.alg);
127+
128+
if (!refresh_token) {
129+
console.log("missing refresh token.");
121130
return logout();
122131
}
123132

124-
if (isEmulatedCredential && !options.emulatorHost) throw new Error("could not detirmine emulator hostname.");
125-
126-
const muchEpochWow = Math.floor(+new Date() / 1000);
127-
if (jwtPayload.exp && jwtPayload.exp > muchEpochWow) {
128-
if (!isEmulatedCredential) {
129-
try {
130-
const jwks = createRemoteJWKSet(new URL('https://www.googleapis.com/robot/v1/metadata/jwk/[email protected]'));
131-
await jwtVerify(authIdToken, jwks);
132-
} catch(e) {
133-
console.error("Jose hates the JWT", e);
134-
return logout();
133+
let isEmulatedCredential;
134+
let jwtPayload;
135+
136+
if (authIdToken) {
137+
138+
let jwtHeader;
139+
try {
140+
[jwtHeader, jwtPayload] = authIdToken.split(".").slice(0,2).map(it => JSON.parse(atob(it))) as [JWTHeaderParameters, FirebaseJWTPayload];
141+
} catch(e) {
142+
console.error("Unable to parse JWT.");
143+
}
144+
145+
if (jwtHeader?.typ !== 'JWT' || jwtPayload?.iss !== `https://securetoken.google.com/${options.projectId}` || jwtPayload.aud !== options.projectId || jwtPayload.firebase?.tenant !== options.tenantId) {
146+
console.error("I hates the claims.");
147+
jwtPayload = undefined;
148+
}
149+
if (jwtHeader?.alg === 'none') {
150+
isEmulatedCredential = true;
151+
} else if (jwtHeader?.alg !== 'RS256') {
152+
console.error("I hates the alg.");
153+
jwtPayload = undefined;
154+
}
155+
156+
if (isEmulatedCredential && !options.emulatorHost) throw new Error("could not determine emulator hostname.");
157+
158+
if (jwtPayload) {
159+
const muchEpochWow = Math.floor(+new Date() / 1000);
160+
if (jwtPayload.exp && jwtPayload.exp > muchEpochWow) {
161+
if (isEmulatedCredential) {
162+
console.log("We good, its emulated.");
163+
return [undefined, it => it, jwtPayload];
164+
} else {
165+
try {
166+
const jwks = createRemoteJWKSet(new URL('https://www.googleapis.com/robot/v1/metadata/jwk/[email protected]'));
167+
await jwtVerify(authIdToken, jwks);
168+
console.log("JOSE is happy.");
169+
return [undefined, it => it, jwtPayload];
170+
} catch(e) {
171+
console.error("JOSE is a hater.");
172+
}
173+
}
174+
} else {
175+
console.error("So exp, wow.");
135176
}
136177
}
137-
const decorateNextResponse = (response: NextResponse) => response;
138-
return [undefined, decorateNextResponse, jwtPayload];
139178
}
140179

141-
const refresh_token = request.cookies.get({...REFRESH_TOKEN_COOKIE, value: "" })?.value;
142-
if (!refresh_token) {
143-
console.error("Where's the refresh token bro?");
144-
return logout();
180+
try {
181+
isEmulatedCredential ??= !!JSON.parse(Buffer.from(decodeURIComponent(refresh_token), 'base64').toString())["_AuthEmulatorRefreshToken"];
182+
} catch(e) {
183+
145184
}
146185

186+
if (isEmulatedCredential && !options.emulatorHost) throw new Error("could not determine emulator hostname.");
187+
188+
console.log("attempting a refresh with", { isEmulatedCredential });
189+
147190
const refreshUrl = new URL(isEmulatedCredential ? `http://${options.emulatorHost}` : `https://securetoken.googleapis.com`);
148191
refreshUrl.pathname = [isEmulatedCredential && 'securetoken.googleapis.com', 'v1/token'].filter(Boolean).join('/');
149192
refreshUrl.searchParams.set("key", options.apiKey);
@@ -162,6 +205,8 @@ async function runMiddleware(options: RunMiddlewareOptions, request: NextRequest
162205
const json = await refreshResponse.json() as any;
163206
const newRefreshToken = json.refresh_token;
164207
const newIdToken = json.id_token;
208+
jwtPayload = JSON.parse(atob(newIdToken.split(".")[1]));
209+
console.log(jwtPayload);
165210
const decorateNextResponse = (response: NextResponse) => {
166211
if (newIdToken) response.cookies.set({ ...ID_TOKEN_COOKIE, value: newIdToken });
167212
if (newRefreshToken) response.cookies.set({ ...REFRESH_TOKEN_COOKIE, value: newRefreshToken });

0 commit comments

Comments
 (0)