I want to implement a sessions management system using Next-Auth - mongodb

I want to implement a manage sessions system, so the user can logout all sessions when he change password.
1- when the user login I will store his session into user sessions array:
2- I'll check if the current session is stored in database, if not I'll log him out.
3- I want to add a "logout all sessions" button that logout all except for current session.
but I don't know how to start, because all I have when user login is:
{
user: { uid: '61a53559b7a09ec93f45f6ad' },
expires: '2021-12-30T16:34:58.437Z',
accessToken: undefined
}
{
user: { uid: '61a53559b7a09ec93f45f6ad' },
iat: 1638290097,
exp: 1640882097,
jti: '2fc85541-eb9b-475e-8261-50c874f85b51'
}
my [next-auth].js :
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import mongoose from "mongoose";
import { compare } from "bcrypt";
import { User } from "../auth/signup"
export default NextAuth({
//configure json web token
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60,
updateAge: 24 * 60 * 60,
},
providers: [
CredentialsProvider({
async authorize(credentials){
//connect to database
const client = await mongoose.connect(process.env.DB_URI);
//search for user using email or username
const result = await User.findOne({$or: [{email: credentials.username}, {username: credentials.username}]});
//if not found
if(!result){
client.connection.close();
throw new Error("Incorrect username or password.");
}
const checkPassword = await compare(credentials.password, result.password);
//of password doesn't match
if(!checkPassword){
client.connection.close();
throw new Error("Incorrect username or password.")
}
client.connection.close();
if(!result.emailVerified.verified){
client.connection.close();
throw new Error("Please verify your email adress.")
}
return {
uid: result._id
};
}
}),
],
callbacks: {
async jwt({ token, user, account }){
if (account) {
token.accessToken = account.access_token
}
user && (token.user = user)
return token
},
async session({ session, token }){
session.user = token.user;
session.accessToken = token.accessToken;
return session;
}
}
});

I created a sessions array for each user, when the user signs in, I generate a random hash key and save it to this array (you can add custom properties such as IP, time.. etc), and then save this hash to cookies, then I added a request in getServerSideProps
export async function getServerSideProps(context){
const {data: isAuthenticated} = await axios.get(`${process.env.WEB_URI}/api/auth/verify/session`, {
headers: {
cookie: context.req.headers.cookie
}
});
/api/auth/verify/session.js
export default async function session(req, res){
if(req.method === "GET"){
const session = await getSession({req: req});
if(!session){
return res.send(false);
}
mongoose.connect(process.env.DB_URI, {useUnifiedTopology: true} , async function(error) {
if(!error){
const user = await User.findOne({uid: session.user.uid}, {_id: 0, sessions: 1});
if(!user){
return res.send(false);
}
const userSession = user.sessions;
if(userSession.length > 0){
const tokens = userSession.map((session => session.token));
if(tokens.includes(session.user.token)){
res.send(true);
}else{
res.send(false);
}
}else{
res.send(false);
}
}
});
}
}
finally
if(props.isAuthenticated === false){
signOut();
}

Related

Persist session id in passport-saml login login callback

I'm using passport-saml and express-session. I login with my original session id but when the idp response reach the login callback handler, I have another sessionId. Also, since my browser has the session cookie with the original session id, it cannot use the new session id in the login callback, so I cannot authenticate.
interface SamlProvider {
name: string;
config: SamlConfig;
}
const providers: SamlProvider[] = [
{
name: process.env.SAML_ENTITY_ID_1!,
config: {
path: "/login/callback",
entryPoint: process.env.SAML_SSO_ENDPOINT_1,
issuer: process.env.SAML_ENTITY_ID_1,
cert: process.env.SAML_CERT_1!,
...(process.env.NODE_ENV === "production" && { protocol: "https" }),
disableRequestedAuthnContext: true,
},
},
{
name: process.env.SAML_ENTITY_ID_2!,
config: {
path: "/login/callback",
entryPoint: process.env.SAML_SSO_ENDPOINT_2,
issuer: process.env.SAML_ENTITY_ID_2,
cert: process.env.SAML_CERT_2!,
...(process.env.NODE_ENV === "production" && { protocol: "https" }),
disableRequestedAuthnContext: true,
},
},
];
export const samlStrategy = (sessionStore: session.Store) =>
new MultiSamlStrategy(
{
passReqToCallback: true, // makes req available in callback
getSamlOptions: function (request, done) {
// Find the provider
const relayState = request.query.RelayState || request.body.RelayState;
const provider = providers.find((p) => p.name === relayState);
if (!provider) {
return done(Error("saml identity provider not found"));
}
return done(null, provider.config);
},
},
async function (
req: Request,
profile: Profile | null | undefined,
done: VerifiedCallback
) {
if (profile && profile.nameID) {
const { nameID, nameIDFormat } = profile;
const email = profile[
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
] as string;
const firstName = profile[
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname"
] as string;
const lastName = profile[
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname"
] as string;
// Check if user is in risk database
const user = await myUserService.getByEmail(email);
if (!user) return done(new UserNotFoundError());
// If user has existing session, delete that existing session
sessionStore.all!((err: any, obj: any) => {
const sessions = obj as Array<{
sid: string;
passport?: { user?: { email?: string } };
}>;
const existingSess = sessions.find(
(sess) =>
sess.passport &&
sess.passport.user &&
sess.passport.user.email &&
sess.passport.user.email === email
);
if (existingSess && existingSess.sid) {
sessionStore.destroy(existingSess.sid, (err: any) => {
console.error(err);
return done(Error("failed to delete existing user session"));
});
}
});
return done(null, { nameID, nameIDFormat, email, firstName, lastName });
}
return done(Error("invalid saml response"));
}
);
Here's my login and login callback
app.post("/login/callback", async function (req, res, next) {
passport.authenticate("saml", (err: any, user: ISessionUser) => {
if (err) {
// TODO: Handle specific errors
logger.info({ label: "SAML Authenticate Error:", error: err });
return next(err);
} else {
req.logIn(user, (err) => {
if (err) {
logger.info({ label: "Login Error:", data: err });
return next(err);
}
res.redirect("/");
});
}
})(req, res, next);
});
app.get(
"/auth/saml/login",
passport.authenticate("saml", { failureRedirect: "/", failureFlash: true }),
function (req, res) {
res.redirect("/");
}
);
I experienced a similar issue using Microsoft 365 for authentication. The answer was to pass a randomly-generated nonce to the authentication request - this gets passed back to your app in the callback request. With SAML I think it depends on the provider whether they support such a flow, but it is good practice. You can also use a cookie to maintain state in your app, instead of, or additional to, the session id.

Get User ID from session in next-auth client

I'm using next-auth with Prisma and Graphql, I followed these instructions to set up the adapter and my models accordingly:
https://next-auth.js.org/adapters/prisma
Authentication works but when I inspect session object from here :
const { data: session, status } = useSession()
I don't see ID
The reason I need the ID is to make further GraphQL queries. I'm using email value for now to fetch the User by email, but having ID available would be a better option.
Here's the quickest solution to your question:
src/pages/api/auth/[...nextAuth].js
export default NextAuth({
...
callbacks: {
session: async ({ session, token }) => {
if (session?.user) {
session.user.id = token.uid;
}
return session;
},
jwt: async ({ user, token }) => {
if (user) {
token.uid = user.id;
}
return token;
},
},
session: {
strategy: 'jwt',
},
...
});
This worked for me.
callbacks: {
async jwt({token, user, account, profile, isNewUser}) {
user && (token.user = user)
return token
},
async session({session, token, user}) {
session = {
...session,
user: {
id: user.id,
...session.user
}
}
return session
}
}
Here's the quickest solution that worked for me
import NextAuth from "next-auth"
import { MongoDBAdapter } from "#next-auth/mongodb-adapter"
import clientPromise from "../../../lib/mongodb"
export const authOptions = {
providers: [
<...yourproviders>
],
callbacks: {
session: async ({ session, token, user }) => {
if (session?.user) {
session.user.id = user.id;
}
return session;
},
},
adapter: MongoDBAdapter(clientPromise),
}
I just referred to the NextAuth docs (this page) and finally got it working the right way
callbacks: {
jwt({ token, account, user }) {
if (account) {
token.accessToken = account.access_token
token.id = user?.id
}
return token
}
session({ session, token }) {
// I skipped the line below coz it gave me a TypeError
// session.accessToken = token.accessToken;
session.user.id = token.id;
return session;
},
}
If you use TypeScript, add this to a new file called next-auth.d.ts
import NextAuth from 'next-auth';
declare module 'next-auth' {
interface Session {
user: {
id: string;
} & DefaultSession['user'];
}
}
I believe you can change the callbacks so it includes the user's ID in the session: https://next-auth.js.org/configuration/callbacks.
You will need to change the JWT callback so it also include the userId and the session callback so the id is also persisted to the browser session.

NextAuth Hasura Refresh token

I am trying to set up NextAuth for Hasura authentication and authorization. Since Hasura needs custom jwt claims I can't use the default access token provided by an OAuth provider. So I am using encode block in [...nextauth].js to encode a custom jwt token and everything works fine. But I don't know how to implement a refresh token for my custom token. Below is my "pages/api/auth/[...nextauth].js"
import * as jwt from "jsonwebtoken";
import NextAuth from "next-auth";
import Providers from "next-auth/providers";
export default NextAuth({
providers: [
Providers.Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
authorizationUrl:
"https://accounts.google.com/o/oauth2/v2/auth?prompt=consent&access_type=offline&response_type=code",
}),
],
secret: process.env.SECRET,
session: {
jwt: true,
},
jwt: {
secret: process.env.SECRET,
encode: async ({ secret, token, maxAge }) => {
const jwtClaims = {
sub: token.id,
name: token.name,
email: token.email,
picture: token.picture,
iat: Date.now() / 1000,
exp: Math.floor(Date.now() / 1000) + 60,
"https://hasura.io/jwt/claims": {
"x-hasura-allowed-roles": ["user"],
"x-hasura-default-role": "user",
"x-hasura-role": "user",
"x-hasura-user-id": token.id,
},
};
const encodedToken = jwt.sign(jwtClaims, secret, { algorithm: "HS256" });
return encodedToken;
},
decode: async ({ secret, token, maxAge }) => {
const decodedToken = jwt.verify(token, secret, { algorithms: ["HS256"] });
return decodedToken;
},
},
pages: {
// signIn: '/auth/signin', // Displays signin buttons
// signOut: '/auth/signout', // Displays form with sign out button
// error: '/auth/error', // Error code passed in query string as ?error=
// verifyRequest: '/auth/verify-request', // Used for check email page
// newUser: null // If set, new users will be directed here on first sign in
},
// Callbacks are asynchronous functions you can use to control what happens
// when an action is performed.
// https://next-auth.js.org/configuration/callbacks
callbacks: {
// async signIn(user, account, profile) { return true },
// async redirect(url, baseUrl) { return baseUrl },
async session(session, token) {
const encodedToken = jwt.sign(token, process.env.SECRET, {
algorithm: "HS256",
});
session.token = encodedToken;
session.id = token.id;
return Promise.resolve(session);
},
async jwt(token, user, account, profile, isNewUser) {
const isUserSignedIn = user ? true : false;
// make a http call to our graphql api
// store this in postgres
if (isUserSignedIn) {
token.id = profile.id.toString();
}
return Promise.resolve(token);
},
},
// Events are useful for logging
// https://next-auth.js.org/configuration/events
events: {},
// Enable debug messages in the console if you are having problems
debug: true,
});
Can somebody tell me how to handle refresh token with next-auth when using custom jwt tokens?
I'm currently using a method I found on a post here on the next-auth issues page.
Essentially, we're using a combination of the clientMaxAge option you can pass into the provider to refetch the session, which reruns the jwt callback. I'm not sure I'm using the keepAlive property correctly, but this seems to poll correctly at the moment, though you may need to experiment with this.
Inside your JWT callback, you can have your logic that will check your existing expiry time against another, and fetch a new token from your server to assign for the session.
//_app.tsx
const sessionOptions = {
clientMaxAge: 60 * 30, // Re-fetch session if cache is older than 30 minutes
keepAlive: 60 * 30, // Send keepAlive message every hour
};
<Provider options={sessionOptions} session={pageProps.session}>
..
</Provider>
// [...nextauth].ts
const callbacks: CallbacksOptions = {
async jwt(token: any, user: any) {
if (user) {
token.accessToken = user.token;
token.expires = Date.now() + user.config.USER_SESSION_LENGTH_IN_SECONDS * 1000;
}
// Don't access user as it's only available once, access token.accessToken instead
if (token?.accessToken) {
const tokenExpiry = token.expires;
const almostNow = Date.now() + 60 * 1000;
if (tokenExpiry !== undefined && tokenExpiry < almostNow) {
// Token almost expired, refresh
try {
const newToken = await api.renewToken(token.accessToken); // calling external endpoint to get a new token
// re-assign to the token obj that will be passed into the session callback
token.accessToken = newToken.token;
token.expires = Date.now() + user.config.USER_SESSION_LENGTH_IN_SECONDS * 1000;
} catch (error) {
console.error(error, 'Error refreshing access token');
}
}
}
return token;
},
async session(session: any, user: any) {
session.accessToken = user.accessToken;
session.expires = user.expires;
return session;
}
}

Set CurrentUser (jwt) to #Post (in events controller)

When a user makes an event
the logged in users id should be saved to the database
there is relation
#ManyToOne(_ => User, user => user.events, {
eager: true,
cascade: true
})
users: User;
in the events entity (many events possibly to one user)
my users entity relation
#OneToMany(_ => Event, event => event.users, {
eager: false
})
events: Event[];
in the database, all the fields (primary ID, name, description, image, startDate, endDate) show up plus a users_id.
That supposedly should take in the logged in users ID
the events controller has an
#Post decorator
#Authorized()
#Post("/events")
#HttpCode(201)
createEvent(#Body() event: Event) {
return event.save();
}
and in the front-end
my action sends all the values for the fields in the database
(when I create an event al the values are stored)
export const addEvent = event => (dispatch, getState) => {
const state = getState();
const jwt = state.currentUser.jwt;
if (isExpired(jwt)) return dispatch(logout());
request
.post(`${baseUrl}/events`)
.set("Authorization", `Bearer ${jwt}`)
.send({
name: event.name,
description: event.description,
startDate: event.startDate,
endDate: event.endDate,
image: event.image
})
.then(response =>
dispatch({
type: ADD_EVENT,
payload: response.body
})
);
};
I also send a jwt for the currentUser
and I have an example coming from
a boilerplate we got for learning to work with web-sockets
#Authorized()
#Post("/games/:id([0-9]+)/players")
#HttpCode(201)
async joinGame(#CurrentUser() user: User, #Param("id") gameId: number) {
const game = await Game.findOneById(gameId);
if (!game) throw new BadRequestError(`Game does not exist`);
if (game.status !== "pending")
throw new BadRequestError(`Game is already started`);
game.status = "started";
await game.save();
const player = await Player.create({
game,
user,
symbol: "o"
}).save();
io.emit("action", {
type: "UPDATE_GAME",
payload: await Game.findOneById(game.id)
});
return player;
}
there when a new game is created
it also stores the user that created the game
So I figured that it has something to do with the
#CurrentUser() user: User
But I have no Idea
how to implement in this projects #Post eventsController
If somebody can tell me how
and with a short explanation of how and why that works
I will keep googling.
I changed the #Post
to
#Authorized()
#Post('/events')
#HttpCode(201)
async createEvent(
#CurrentUser() user: User,
#Body() event: Event,
) {
if (user instanceof User) event.users = user
const entity = event.save()
return { entity }
}
}
and apparently it needed a
currentUserChecker function
currentUserChecker: async (action: Action) => {
const header: string = action.request.headers.authorization
if (header && header.startsWith('Bearer ')) {
const [, token] = header.split(' ')
if (token) {
const { id } = verify(token)
return User.findOne(id)
}
}
return undefined
}
and I had to change the jwt.ts
from
import * as jwt from 'jsonwebtoken'
const secret = process.env.JWT_SECRET || '9u8nnjksfdt98*(&*%T$#hsfjk'
const ttl = 3600 * 4 // our JWT tokens are valid for 4 hours
interface JwtPayload {
id: number
}
export const sign = (data: JwtPayload) =>
jwt.sign({ data }, secret, { expiresIn: ttl })
export const verify = (token: string): { data: JwtPayload } =>
jwt.verify(token, secret) as { data: JwtPayload }
to
import * as jwt from 'jsonwebtoken'
const secret = process.env.JWT_SECRET || '9u8nnjksfdt98*(&*%T$#hsfjk'
const ttl = 3600 * 4 // our JWT tokens are valid for 4 hours
interface JwtPayload {
id: number
}
export const sign = (data: JwtPayload) =>
jwt.sign({ data }, secret, { expiresIn: ttl })
export const verify = (token: string): JwtPayload =>
jwt.verify(token, secret) as JwtPayload

Accounts.createUser without username, password and email

My application is built with React, which is completely separate from Meteor. I use Asteroid to interface to Meteor which serves as backend only. I have manually created the Facebook login button at front end and want to pass the data fetched from Facebook to Accounts.createUser. This method asks for two parameters which is not available because I have formatted it like so:
const data = {
services: {
facebook: fb
},
profile: {
first_name: fb.first_name,
last_name: fb.last_name,
}
}
I have created a method as below but I failed to log the user in with appropriate token or what ever indicator that Meteor needed:
getLoginByExternalService(options) {
if (Meteor.userId()) throw new Meteor.Error('400',`Please logout ${Meteor.userId()}`);
const email = options.services.facebook.email
const facebookId = options.services.facebook.id
const user = {services: {}}
user.services = options.services
const users = Meteor.users.find({"services.facebook.id": facebookId}).fetch();
if (!users.length) {
const userId = Accounts.insertUserDoc(options, user)
if (Meteor.isServer)
this.setUserId(userId)
else
Meteor.setUserId(userId)
return userId
} else {
if (Meteor.isServer)
this.setUserId(users[0]._id)
if (Meteor.isClient)
Meteor.setUserId(userId)
return {users, userId: Meteor.userId()}
}
}
How to properly log the user in?
Okay I already got the answer. I don't have to format the data return from facebook response. So here the implementation at the backend
getLoginByExternalService(resp) {
if (Meteor.userId()) Meteor.logout(Meteor.userId()) //who knows?
const accessToken = resp.accessToken
const identity = getIdentity(accessToken)
const profilePicture = getProfilePicture(accessToken)
const serviceData = {
accessToken: accessToken,
expiresAt: (+new Date) + (1000 * resp.expiresIn)
}
const whitelisted = ['id', 'email', 'name', 'first_name', 'last_name', 'link', 'username', 'gender', 'locale', 'age_range']
const fields = _.pick(identity, whitelisted)
const options = {profile: {}}
const profileFields = _.pick(identity, getProfileFields())
//creating the token and adding to the user
const stampedToken = Accounts._generateStampedLoginToken()
//hashing is something added with Meteor 0.7.x,
//you don't need to do hashing in previous versions
const hashStampedToken = Accounts._hashStampedToken(stampedToken)
let ref = null
_.extend(serviceData, fields)
_.extend(options.profile, profileFields)
options.profile.avatar = profilePicture
try {
ref = Accounts.updateOrCreateUserFromExternalService("facebook", serviceData, options);
} catch (e) {
if (e.reason === "Email already exists.") {
const existingUser = Meteor.users.findOne({ 'emails.address': identity.email })
if ( existingUser ) {
if ( identity.verified ) {
Meteor.users.update({ _id: existingUser._id }, { $set: { 'services.facebook': serviceData }})
ref = { userId: existingUser._id }
console.log(`Merged facebook identity with existing local user ${existingUser._id}`);
} else {
throw Meteor.Error(403, "Refusing to merge unverified facebook identity with existing user")
}
}
} else {
throw Meteor.Error(e.error, e.reason)
}
}
Meteor.users.update(ref.userId, {$push: {'services.resume.loginTokens': hashStampedToken}})
return {id: ref.userId, token: stampedToken.token}
}
so somewhere at the front end
asteroid.call("getLoginByExternalService", data).then(response => response)