I recently just switched from using express with apollo server to just using apollo server since the subscriptions setup seemed more current and easier to setup. The problem I'm having now is I was saving a cookie with our refresh token for login and clearing the cookie on logout. This worked when I was using express.
const token = context.req.cookies[process.env.REFRESH_TOKEN_NAME!];
context.res.status(401);
Since switching from express/apollo to just apollo server I don't have access to req.cookies even when i expose the req/res context on apollo server.
I ended up switching to this (which is hacky) to get the cookie.
const header = context.req.headers.cookie
var cookies = header.split(/[;] */).reduce(function(result: any, pairStr: any) {
var arr = pairStr.split('=');
if (arr.length === 2) { result[arr[0]] = arr[1]; }
return result;
}, {});
This works but now I can't figure out how to delete the cookies. With express I was doing
context.res.clearCookie(process.env.REFRESH_TOKEN_NAME!);
Not sure how I can clear cookies now since res.clearCookie doesn't exist.
You do not have to specifically clear the cookies. The expiresIn cookie key does that for you. Here is the snippet which i used to set cookies in browser from apollo-server-lambda. Once the expiresIn date values has passed the current date time then the cookies wont be valid for that host/domain. You need to revoke access token for the user again or logout the user from the application
import { ApolloServer, AuthenticationError } from "apollo-server-lambda";
import resolvers from "./src/graphql/resolvers";
import typeDefs from "./src/graphql/types";
const { initConnection } = require("./src/database/connection");
const { validateAccessToken, hasPublicEndpoint } = require("./src/bll/user-adapter");
const { addHoursToDate } = require("./src/helpers/utility");
const corsConfig = {
cors: {
origin: "http://localhost:3001",
credentials: true,
allowedHeaders: [
"Content-Type",
"Authorization"
],
},
};
// creating the server
const server = new ApolloServer({
// passing types and resolvers to the server
typeDefs,
resolvers,
context: async ({ event, context, express }) => {
const cookies = event.headers.Cookie;
const accessToken = ("; " + cookies).split(`; accessToken=`).pop().split(";")[0];
const accessLevel = ("; " + cookies).split(`; accessLevel=`).pop().split(";")[0];
const expiresIn = ("; " + cookies).split(`; expiresIn=`).pop().split(";")[0];
const { req, res } = express;
const operationName = JSON.parse(event.body).operationName;
if (await hasPublicEndpoint(operationName)) {
console.info(operationName, " Is a public endpoint");
} else {
if (accessToken) {
const jwtToken = accessToken.split(" ")[1];
try {
const verifiedUser = await validateAccessToken(jwtToken);
console.log("verifiedUser", verifiedUser);
if (verifiedUser) {
return {
userId: verifiedUser,
};
} else {
console.log();
throw new AuthenticationError("Your token does not verify!");
}
} catch (err) {
console.log("error", err);
throw new AuthenticationError("Your token does not verify!");
}
}
}
return {
headers: event.headers,
functionName: context.functionName,
event,
context,
res,
};
},
cors: corsConfig,
formatResponse: (response, requestContext) => {
if (response.data?.authenticateUser || response.data?.revokeAccessToken) {
// console.log(requestContext.context);
const { access_token, user_type, access_token_generated_on, email } =
response.data.authenticateUser || response.data.revokeAccessToken;
const expiresIn = addHoursToDate(new Date(access_token_generated_on), 12);
requestContext.context.res.set("set-cookie", [
`accessToken=Bearer ${access_token}`,
`accessLevel=${user_type}`,
`expiresIn=${new Date(access_token_generated_on)}`,
`erUser=${email}`,
]);
}
if (response.data?.logoutUser) {
console.log("Logging out user");
}
return response;
},
});
Simply send back the exact same cookie to the client with an Expires attribute set to some date in the past. Note that everything about the rest of the cookie has to be exactly the same, so be sure to keep all the original cookie attributes, too.
And, here's a link to the RFC itself on this topic:
Finally, to remove a cookie, the server returns a Set-Cookie header
with an expiration date in the past. The server will be successful
in removing the cookie only if the Path and the Domain attribute in
the Set-Cookie header match the values used when the cookie was
created.
As to how to do this, if you're using Node's http module, you can just use something like this (assuming you have a response coming from the callback passed to http.createServer):
context.response.writeHead(200, {'Set-Cookie': '<Your Cookie Here>', 'Content-Type': 'text/plain'});
This is assuming that your context has access to that http response it can write to.
For the record, you can see how Express does it here and here for clarity.
Related
I am trying to send a cookie with options set to it as a response using a Firebase callable cloud function (https.onCall). I see in the Firebase docs that this can be done with express:
(The below is taken directly form the Firebase docs)
app.post('/sessionLogin', (req, res) => {
// Get the ID token passed and the CSRF token.
const idToken = req.body.idToken.toString();
const csrfToken = req.body.csrfToken.toString();
// Guard against CSRF attacks.
if (csrfToken !== req.cookies.csrfToken) {
res.status(401).send('UNAUTHORIZED REQUEST!');
return;
}
// Set session expiration to 5 days.
const expiresIn = 60 * 60 * 24 * 5 * 1000;
// Create the session cookie. This will also verify the ID token in the process.
// The session cookie will have the same claims as the ID token.
// To only allow session cookie setting on recent sign-in, auth_time in ID token
// can be checked to ensure user was recently signed in before creating a session cookie.
getAuth()
.createSessionCookie(idToken, { expiresIn })
.then(
(sessionCookie) => {
// Set cookie policy for session cookie.
const options = { maxAge: expiresIn, httpOnly: true, secure: true };
res.cookie('session', sessionCookie, options);
res.end(JSON.stringify({ status: 'success' }));
},
(error) => {
res.status(401).send('UNAUTHORIZED REQUEST!');
}
);
});
I have implemented the callable function, but I do now know how to attach the options to my cookie string.
The below is my code:
// I want the return type to be a Promise of a cookie object, not a string
export const setCookie = https.onCall(async (context: https.CallableContext): Promise<string> => {
try {
console.log(context);
const auth: Auth = getAuth();
const idToken: DecodedIdToken = await auth.verifyIdToken(context.instanceIdToken!); // https://firebase.google.com/docs/auth/admin/verify-id-tokens#web
console.log("idToken: ", idToken);
const cookie: string = await auth.createSessionCookie(idToken.uid, { expiresIn: 300000 });
const options = {
maxAge: 300000,
httpOnly: true,
secure: true,
sameSite: "strict",
};
// res.cookie("session", cookie, options);
return cookie; // should be assigned to __session cookie with domain .web.app
// httpOnly=true, secure=true and sameSite=strict set.
} catch (error) {
console.log("ERROR FOUND: ", error);
throw new https.HttpsError("unknown", "Error found in setCookie");
}
});
Is there any way I can do this using a Callable Firebase Cloud Function? All the documentation and resources I have found require express to send an cookie with Node.
Thanks!
The documentation you're linking to assumes you are writing standard nodejs backend code using express. However, your code is using a callable type function. They are not the same and do not have the same capabilities. Callable functions don't let you set cookies in the response. You can only send a JSON payload back to the client; the SDK handles all of the HTTP headers and they are outside of your control.
Perhaps you should look into using a standard HTTP type function (onRequest), where you do have some control over the headers in the response.
I am trying to login using TikTok oAuth API
I have a Firebase Cloud Function (Nodejs) set up to complete the oauth flow, based
on the TikTok API Documentation, but when i reach the point (https://open-api.tiktok.com/oauth/access_token) to get the actual user access token it fails and i get an error.
The response i get is status 200 and
{
"data": {
"captcha": "",
"desc_url": "",
"description": "Authorization code expired",
"error_code": 10007
},
"message": "error"
}
The TikTok API always gives me the same authorization code. So i am guessing something is wrong. Any suggestion is welcomed.
Here is the code sample from the backend
The /linkTikTok/oauth and point used to redirect the user to tikTok oauth and the /linkTikTok/validate is used to request the access token. The code runs fine but when it reaches const URL = https://open-api.tiktok.com/oauth/access_token; and actually requests the user access token i get response above.
import * as express from 'express';
import * as cors from 'cors';
import axios from 'axios';
import * as cookieParser from 'cookie-parser';
import { config } from 'firebase-functions';
import { firestore } from 'firebase-admin';
import { colRefs } from '../../constants/db-refs';
const app = express();
app.use(cors());
app.use(cookieParser());
app.listen();
const { client_key, client_secret } = config().tikTokCredentials;
const redirectURI = `https://xxxxx.firebaseapp.com/linkTikTok/validate`;
app.get('/linkTikTok/oauth', async (req, res) => {
// The user's id;
const uid = 'a_user_id';
if (!uid) {
return res.status(401).send('This action requires user authentication');
}
// Random state
const csrfState = Math.random().toString(36).substring(7);
const state: any = {
state: csrfState,
timestamp: firestore.Timestamp.now(),
uid,
};
// A state object kepts in firestore
await colRefs.tikTokAuthState.doc(uid).set(state);
res.cookie('__session', { state: csrfState });
let url = 'https://open-api.tiktok.com/platform/oauth/connect/';
url += `?client_key=${client_key}`;
url += '&scope=user.info.basic,video.list';
url += '&response_type=code';
url += `&redirect_uri=${redirectURI}`;
url += '&state=' + csrfState;
return res.redirect(url);
});
app.get('/linkTikTok/validate', async (req, res) => {
// Query state
const state = req.query.state as string;
if (!state) {
return res.status(403).send('No state found');
}
const code = req.query.code as string;
if (!code) {
return res.status(403).send('No code found');
}
const sessionCookie = req.cookies['__session'] ?? {};
const sessionState = sessionCookie.state;
if (state !== sessionState) {
return res.status(403).send('Wrong state');
}
// Retrieve the uid from firestore
const uid = await (async () => {
const states = (await colRefs.tikTokAuthState.where('state', '==', state).get()).docs.map(d => d.data());
if (states.length !== 0 && states.length > 1) {
console.warn('More than one state');
}
return states[0].uid;
})();
console.log({ uid });
const URL = `https://open-api.tiktok.com/oauth/access_token`;
const params = {
client_key,
client_secret,
code,
grant_type: 'authorization_code',
};
try {
const result = await axios.post<any>(URL, '', {
params,
});
const data = result.data.data;
const {
access_token: accessToken,
refresh_token,
refresh_expires_in,
open_id: openId,
expires_in,
} = data;
if (!accessToken) {
throw new Error('No access token found');
}
// Application logic
...
});
would you share the piece of code you've written so that we could find the spot.
I got the same error in my code, however, in my case, I was doing duplicate authentication with the TikTok API, because I forgot the "code" GET parameter in my URL and when I was saving settings in my app again, the GET parameter fired again the authentication sequence and I got always the "Authorization code expired" error - but only the second time I was making requests.
You should check if you don't also have duplicate authentication requests in your app.
We have a frontend application that uses Vue3 and a backend that uses nodejs+express.
We are trying to make it so once the frontend application is authorised by keycloak it can then pass a bearer token to the backend (which is also protected by keycloak in the same realm), to make the API calls.
Can anyone suggest how we should be doing this?
Follows is what we are trying and seeing as a result.
The error thrown back is simply 'Access Denied', with no other details Running the debugger we see a 'invalid token (wrong audience)' error thrown in the GrantManager.validateToken function (which unfortunately doesn't bubble up).
The frontend makes use of #dsb-norge/vue-keycloak-js which leverages keycloak-js.
The backend makes use of keycloak-connect. Its endpoints are REST based.
In the webapp startup we initialise axios as follows, which passes the bearer token to the backend server
const axiosConfig: AxiosRequestConfig = {
baseURL: 'http://someurl'
};
api = axios.create(axiosConfig);
// include keycloak token when communicating with API server
api.interceptors.request.use(
(config) => {
if (app.config.globalProperties.$keycloak) {
const keycloak = app.config.globalProperties.$keycloak;
const token = keycloak.token as string;
const auth = 'Authorization';
if (token && config.headers) {
config.headers[auth] = `Bearer ${token}`;
}
}
return config;
}
);
app.config.globalProperties.$api = api;
On the backend, during the middleware initialisation:
const keycloak = new Keycloak({});
app.keycloak = keycloak;
app.use(keycloak.middleware({
logout: '/logout',
admin: '/'
}));
Then when protecting the endpoints:
const keycloakJson = keystore.get('keycloak');
const keycloak = new KeycloakConnect ({
cookies: false
}, keycloakJson);
router.use('/api', keycloak.protect('realm:staff'), apiRoutes);
We have two client configured in Keycloak:
app-frontend, set to use access type 'public'
app-server, set to use access type 'bearer token'
Trying with $keycloak.token gives us the 'invalid token (wrong audience)' error, but if we try with $keycloak.idToken instead, then we get 'invalid token (wrong type)'
In the first case it is comparing token.content.aud of value 'account', with a clientId of app-server. In the second case it is comparing token.content.typ, of value 'ID' with an expected type of 'Bearer'.
Upon discussion with a developer on another projects, it turns out my approach is wrong on the server and that keycloak-connect is the wrong tool for the job. The reasoning is that keycloak-connect is wanting to do its own authentication flow, since the front-end token is incompatible.
The suggested approach is to take the bearer token provided in the header and use the jwt-uri for my keycloak realm to verify the token and then use whatever data I need in the token.
Follows is an early implementation (it works, but it needs refinement) of the requireApiAuthentication function I am using to protect our endpoints:
import jwksClient from 'jwks-rsa';
import jwt, { Secret, GetPublicKeyOrSecret } from 'jsonwebtoken';
// promisify jwt.verify, since it doesn't do promises
async function jwtVerify (token: string, secretOrPublicKey: Secret | GetPublicKeyOrSecret): Promise<any> {
return new Promise<any>((resolve, reject) => {
jwt.verify(token, secretOrPublicKey, (err: any, decoded: object | undefined) => {
if (err) {
reject(err);
} else {
resolve(decoded);
}
});
});
}
function requireApiAuthentication (requiredRole: string) {
// TODO build jwksUri based on available keycloak configuration;
const baseUrl = '...';
const realm = '...';
const client = jwksClient({
jwksUri: `${baseUrl}/realms/${realm}/protocol/openid-connect/certs`
});
function getKey (header, callback) {
client.getSigningKey(header.kid, (err: any, key: Record<string, any>) => {
const signingKey = key.publicKey || key.rsaPublicKey;
callback(null, signingKey);
});
}
return async (req: Request, res: Response, next: NextFunction) => {
const authorization = req.headers.authorization;
if (authorization && authorization.toLowerCase().startsWith('bearer ')) {
const token = authorization.split(' ')[1];
const tokenDecoded = await jwtVerify(token, getKey);
if (tokenDecoded.realm_access && tokenDecoded.realm_access.roles) {
const roles = tokenDecoded.realm_access.roles;
if (roles.indexOf(requiredRole) > -1) {
next();
return;
}
}
}
next(new Error('Unauthorized'));
};
}
and then used as follows:
router.use('/api', requireApiAuthentication('staff'), apiRoutes);
i have now stored my jwt in cookies when user sign in or sign up but the data don't stay so i made a function to handle this but i need the value of the token to make it work
this is the function that i need token value for
const setAuthToken = (token) => {
if (token) {
axios.defaults.headers.common['x-auth-token'] = token;
} else {
delete axios.defaults.headers.common['x-auth-token'];
}
};
and this is my action that i use in react to send the token value to this function i tried to use js-cookies for that but it give me undefined
import Cookies from 'js-cookie';
//load user
export const loadUser = () => async (dispatch) => {
const token = Cookies.get('access_token');
console.log(token);
// if (cookie.access_token) {
// setAuthToken(cookie.access_token);
// }
try {
const res = await axios.get('/user/me');
dispatch({
type: USER_LOADED,
payload: res.data,
});
} catch (err) {
dispatch({
type: AUTH_ERROR,
});
}
};
and this is my recieved cookie in browser
If you take a close look at your screenshot, you can see that the cookie is sent by the server as HttpOnly. This is a security measure, and therefore the cookie isn't accessible to any JavaScript code by design.
See https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Restrict_access_to_cookies
If you are in control of the server, you could change it accordingly, if not you will have to make a deal :-)
res.cookie('x-auth-token',token,{
maxAge: 3600,
httpOnly: true,
secure:true
})
I know that each HTTP function must end with end() or send(), so I'm thinking that might be related to my issue. I'm building a Shopify app that I want to host on Firebase. I've gotten it to authenticate and install, but when I try to capture the permanent access token via POST, Firebase times out. This same code works fine with ngrok. Entire route function below.
const dotenv = require('dotenv').config();
const functions = require('firebase-functions');
const express = require('express');
const app = express();
const crypto = require('crypto');
const cookie = require('cookie');
const nonce = require('nonce')();
const querystring = require('querystring');
const request = require('request-promise');
const apiKey = process.env.SHOPIFY_API_KEY;
const apiSecret = process.env.SHOPIFY_API_SECRET;
const scopes = 'read_products,read_customers';
const forwardingAddress = 'https://my-custom-app.firebaseapp.com/app';
app.get('/app/shopify/callback', (req, res) => {
const { shop, hmac, code, state } = req.query;
const stateCookie = cookie.parse(req.headers.cookie).__session;
if (state !== stateCookie) {
return res.status(403).send('Request origin cannot be verified');
}
if (shop && hmac && code) {
// DONE: Validate request is from Shopify
const map = Object.assign({}, req.query);
delete map['signature'];
delete map['hmac'];
const message = querystring.stringify(map);
const generatedHash = crypto
.createHmac('sha256', apiSecret)
.update(message)
.digest('hex');
if (generatedHash !== hmac) {
return res.status(400).send('HMAC validation failed');
}
// Collect permanent access token
const accessTokenRequestUrl = 'https://' + shop + '/admin/oauth/access_token';
const accessTokenPayload = {
client_id: apiKey,
client_secret: apiSecret,
code,
};
// Everything works up until here
request.post(accessTokenRequestUrl, { json: accessTokenPayload })
.then((accessTokenResponse) => {
const accessToken = accessTokenResponse.access_token;
// If below is uncommented, it will not show on browser, Firebase seems to timeout on the above request.post.
//res.status(200).send("Got an access token, let's do something with it");
// Use access token to make API call to 'shop' endpoint
const shopRequestUrl = 'https://' + shop + '/admin/shop.json';
const shopRequestHeaders = {
'X-Shopify-Access-Token': accessToken,
};
request.get(shopRequestUrl, { headers: shopRequestHeaders })
.then((shopResponse) => {
res.end(shopResponse);
})
.catch((error) => {
res.status(error.statusCode).send(error.error.error_description);
});
})
.catch((error) => {
res.status(error.statusCode).send(error.error.error_description);
});
} else {
res.status(400).send('Required parameters missing');
}
});
exports.shopifyValidate = functions.https.onRequest(app);
You're calling response.end() incorrectly:
request.get(shopRequestUrl, { headers: shopRequestHeaders })
.then((shopResponse) => {
res.end(shopResponse);
})
As you can see from the linked documentation, end() doesn't take a parameter. It just ends the response. You probably want to be calling send() instead if you have data to send.
If you're unsure how your function is executing, also use console.log() to log messages to figure out exactly what it's doing. It's rarely a good idea to just hope that a bunch of code is just working - you should verify that it's working the way you expect.
Solved. Turns out you need a paid plan (Blaze, pay as you go) to access external APIs. I upgraded and that solved the issue.
What is the request module that you are using for the request.post()
Please see : https://www.npmjs.com/package/request#promises--asyncawait
I hope you are using the https://github.com/request/request-promise module instead of request.