sveltekit-openid-connect

2.0.1 • Public • Published

SvelteKit OpenID Connect

This is an attempt to port express-openid-connect for use with SvelteKit

NPM version

Open issues for questions or concerns

Table of Contents

Install (Pending)

Node.js version >=16.0.0 is recommended

npm install sveltekit-openid-connect

Getting Started

Initializing

svelte.config.js

const config = {
	kit: {
		csrf: { // This is required due to a breaking change in sveltekit see https://github.com/starbasehq/sveltekit-openid-connect/issues/11
			checkOrigin: false
		}
	}
}

src/hooks.server.js

import * as cookie from 'cookie'
import { TokenUtils } from 'sveltekit-openid-connect'
import { SessionService } from '$lib/services' // This is a service that provides session storage, not a part of this package
import fetch from 'node-fetch'

const {
    AUTH0_DOMAIN,
    AUTH0_BASE_URL,
    AUTH0_CLIENT_ID,
    AUTH0_CLIENT_SECRET,
    COOKIE_SECRET,
    AUTH0_AUDIENCE,
	CSRF_ALLOWED
} = process.env

const csrfAllowed = [`https://${AUTH0_DOMAIN}`, ...(CSRF_ALLOWED || '').split(',').filter(Boolean)]

const sessionName = 'sessionName'
const auth0config = {
    attemptSilentLogin: true,
    authRequired: false,
    auth0Logout: true, // Boolean value to enable Auth0's logout feature.
    baseURL: AUTH0_BASE_URL,
    clientID: AUTH0_CLIENT_ID,
    issuerBaseURL: `https://${AUTH0_DOMAIN}`,
    secret: COOKIE_SECRET,
    clientSecret: AUTH0_CLIENT_SECRET,
    authorizationParams: {
        scope: 'openid profile offline_access email',
        response_type: 'code id_token',
        audience: AUTH0_AUDIENCE
    },
    session: {
        name: 'sessionName', // Replace with custom session name
        cookie: {
            path: '/'
        },
        absoluteDuration: 86400,
        rolling: false,
        rollingDuration: false
    }
}

// This was added to support decrypting the encrypted session cookies we utilized
const tokenUtils = new TokenUtils(auth0config)

export async function handle ({ event, resolve }) {
	const { forbidden: forbidCSRF, response: responseCSRF } = checkCSRF(event.request)
	if (forbidCSRF) return responseCSRF
    try {
        const request = event.request
        event.locals.isAuthenticated = false
        const cookies = cookie.parse(request.headers.get('cookie') || '')
        const { url, body, params } = request
        const path = url.pathname
        const query = url.searchParams
        let sessionCookie

		let sessionValid = false
		let session = {}
		if (cookies.session_id) event.locals.sessionId = cookies.session_id
        try {
			if (event.cookies.get('session_id') && event.cookies.get(sessionName)) {
				const cookieToken = event.cookies.get(sessionName)
				session = await sessionService.get(cookies.session_id, cookieToken)
				try {
					if (tokenUtils.isExpired(cookieToken)) {
						console.warn('Token is expired, try to renew')
						// TODO: Needs Testing
						// TODO: Support refresh tokens?
						return Response.redirect(`/auth/login?returnTo=${event.url.pathname}`, 401)
					} else {
						const idToken = tokenUtils.getIdToken({ token: cookieToken })
						const sToken = session.data
						if (sToken.exp < idToken.exp) {
							console.debug('Cookie is Newer, update session')
							// Update session from your session service
							await session.save()
						}
						if (sToken.sub === idToken.sub && sToken.iss === idToken.iss) {
							console.info('Cookie and Session match')
							sessionValid = true
						} else {
							console.error('Cookie and Session failed to match, do something')
						}
						console.info('Token is valid, not expired')
					}
				} catch (tErr) {
					console.trace(tErr)
				}

				event.locals.sessionId = cookies.session_id

				if (session) {
                    // assign session information to event.locals here
                    /*
                        event.locals.user = session.data.user
                    */
                }
            } else {
                console.warn('No session found, better send to auth')
				// perform sveltekit redirect
            }
        } catch (err) {
            console.error('problem getting app session', err.message)
            // perform sveltekit redirect
        }

        const response = await resolve(request) // This is required by sveltekit

        // Optional: add the session cookie
        if (sessionCookie) {
            const existingCookies = (response.headers && response.headers.get('set-cookie')) ? response.headers.get('set-cookie') : []
            response.headers.set('set-cookie', [...existingCookies, sessionCookie])
        }

        return response
    } catch (err) {
        console.error('Problem running handle', err.message)
    }
}

export async function getSession (event) {
    // This has been deprecated in sveltekit, it is safe to delete, it is moved to +layout.server.js
}

// This is required due to sveltekit changes see https://github.com/starbasehq/sveltekit-openid-connect/issues/11
function checkCSRF (request) {
	const url = new URL(request.url)
	const type = request.headers.get('content-type')?.split(';')[0]
	const forbidden =
		request.method === 'POST' &&
		!_.includes([url.origin, ...csrfAllowed], request.headers.get('origin')) &&
		(type === 'application/x-www-form-urlencoded' || type === 'multipart/form-data')

	if (forbidden) {
		console.warn('Prevent CSRF')
		const response = new Response(`Cross-site ${request.method} form submissions are forbidden`, {
			status: 403
		})
		return { forbidden, response }
	} else {
		return { forbidden, response: null }
	}
}

src/routes/+layout.server.js

export async function load ({ locals }) {
	return {
		session: {
			isAuthenticated: locals.isAuthenticated,
			sessionId: locals.sessionId,
			user: locals.user
		}
	}
}

Logging in

The endpoint route can be different but must be changed in the config block for routes

src/routes/auth/login/+server.js

import { Auth } from 'sveltekit-openid-connect'

const {
    AUTH0_DOMAIN,
    AUTH0_BASE_URL,
    AUTH0_CLIENT_ID,
    AUTH0_CLIENT_SECRET,
    COOKIE_SECRET,
    AUTH0_AUDIENCE
} = process.env

const auth0config = {
    attemptSilentLogin: true,
    authRequired: false,
    auth0Logout: true, // Boolean value to enable Auth0's logout feature.
    baseURL: AUTH0_BASE_URL,
    clientID: AUTH0_CLIENT_ID,
    issuerBaseURL: `https://${AUTH0_DOMAIN}`,
    secret: COOKIE_SECRET,
    clientSecret: AUTH0_CLIENT_SECRET,
    authorizationParams: {
        scope: 'openid profile offline_access email groups permissions roles',
        response_type: 'code id_token',
        audience: AUTH0_AUDIENCE
    },
    routes: {
        login: '/auth/login',
        logout: '/auth/logout',
        callback: '/auth/callback'
    }
}

const auth0 = new Auth(auth0config)

export async function get ({ request }, ...otherProps) {
    const loginResponse = await auth0.handleLogin()

	return new Response(JSON.stringify({}), {
		status: 302,
		headers: {
			location: loginResponse.authorizationUrl,
			'Set-Cookie': loginResponse.cookies
		}
	})
}

Handling the callback

The endpoint route can be different but must be changed in the config block for routes

src/routes/auth/callback/+server.js

import _ from 'lodash'
import * as cookie from 'cookie'
import { Auth, appSession } from 'sveltekit-openid-connect'
import mock from 'mock-http'
import { SessionService } from '$lib/services'

const sessionService = new SessionService()

const {
    AUTH0_DOMAIN,
    AUTH0_BASE_URL,
    AUTH0_CLIENT_ID,
    AUTH0_CLIENT_SECRET,
    COOKIE_SECRET,
    AUTH0_AUDIENCE
} = process.env

const auth0config = {
    attemptSilentLogin: true,
    authRequired: false,
    auth0Logout: true, // Boolean value to enable Auth0's logout feature.
    baseURL: AUTH0_BASE_URL,
    clientID: AUTH0_CLIENT_ID,
    issuerBaseURL: `https://${AUTH0_DOMAIN}`,
    secret: COOKIE_SECRET,
    clientSecret: AUTH0_CLIENT_SECRET,
    authorizationParams: {
        scope: 'openid profile offline_access email',
        response_type: 'code id_token',
        audience: AUTH0_AUDIENCE
    },
    session: {
        name: 'sessionName',
        cookie: {
            path: '/'
        },
        absoluteDuration: 86400,
        rolling: false,
        rollingDuration: false
    }
}

const auth0 = new Auth(auth0config)

export async function post ({ request }) {
    const { headers } = request
    const body = await request.formData()
    const cookies = cookie.parse(headers.get('cookie') || '')
    if (_.isObject(cookies)) {
        const req = new mock.Request({
            url: request.url,
            method: 'POST',
            headers,
            buffer: Buffer.from(JSON.stringify({
                code: body.get('code'),
                state: body.get('state'),
                id_token: body.get('id_token')
            }))
        })
        req.cookies = cookies
        req.body = {
            code: body.get('code'),
            state: body.get('state'),
            id_token: body.get('id_token')
        }

        const res = new mock.Response()

        const authResponse = await auth0.handleCallback(req, res, cookies)
        const session = await appSession(auth0config)(req, res, authResponse.session)

		// Optional to allow restoring an existing session
		const rReturn = new URL(authResponse.redirect.returnTo)
		let sessionId = cookies['session_id'] || rReturn.searchParams.get('sid')
		let sessionRestored = false
		let sessionCookie

		if (sessionId) {
			const restoredSession = await sessionService.restoreSession(sessionId, authResponse.session)
			if (restoredSession.ok) {
				sessionRestored = true
			}
		}

		if (!sessionRestored) {
			const newSession = await sessionService.createSession(authResponse.session)
			sessionId = newSession.sessionId
			sessionCookie = cookie.serialize('session_id', sessionId, {
				httpOnly: true,
				maxAge: 60 * 60 * 24 * 30,
				sameSite: 'lax',
				path: '/'
			})
		}

        return new Response(JSON.stringify({
			error: false
		}), {
			status: 302,
			headers: {
				location: '/',
				'set-cookie': _.concat(authResponse.cookies, session.cookies, sessionCookie).filter(Boolean)
			}
		})
    } else {
		return new Response(JSON.stringify({
			error: true
		}))
	}
}

Destroying the Session

src/routes/auth/logout/+server.js

import * as cookie from 'cookie'
import { Auth } from 'sveltekit-openid-connect'
import mock from 'mock-http'

const {
    AUTH0_DOMAIN,
    AUTH0_BASE_URL,
    AUTH0_CLIENT_ID,
    AUTH0_CLIENT_SECRET,
    COOKIE_SECRET,
    AUTH0_AUDIENCE
} = process.env

const auth0config = {
    attemptSilentLogin: true,
    authRequired: false, // Require authentication for all routes.
    auth0Logout: true, // Boolean value to enable Auth0's logout feature.
    baseURL: AUTH0_BASE_URL,
    clientID: AUTH0_CLIENT_ID,
    issuerBaseURL: `https://${AUTH0_DOMAIN}`,
    secret: COOKIE_SECRET,
    clientSecret: AUTH0_CLIENT_SECRET,
    authorizationParams: {
        scope: 'openid profile offline_access email groups permissions roles',
        response_type: 'code id_token',
        audience: AUTH0_AUDIENCE
    },
    session: {
        name: 'sessionName',
        cookie: {
            path: '/'
        },
        absoluteDuration: 86400,
        rolling: false,
        rollingDuration: false
    },
    routes: {
        login: '/auth/login',
        logout: '/auth/logout',
        callback: '/auth/callback'
    }
}

const auth0 = new Auth(auth0config)

export async function get ({ locals, request }) {
    const { headers } = request
    const cookies = cookie.parse(headers.get('cookie') || '')

    const res = new mock.Response()
    const logoutResponse = await auth0.handleLogout(request, res, cookies, Object.assign(locals))

	// Optional, remove this if you want to support restoring previous session
    const sessionCookie = cookie.serialize('session_id', 'deleted', {
        httpOnly: true,
        expires: new Date(),
        sameSite: 'lax',
        path: '/'
    })

    return new Response(JSON.stringify({}), {
		status: 302,
		headers: {
			location: logoutResponse.returnURL,
			'Set-Cookie': [...logoutResponse.cookies]
		}
	})
}

Sample Session Service

src/lib/services/session.js

import { jwtDecode } from 'jwt-decode'
import { v4 as uuidv4 } from 'uuid'
import DB from './db' // Custom database service using sequelize
import UserService from './user'

const db = new DB()
const userService = new UserService()

class SessionService {
    async createSession (authSession) {
        const sqldb = await db.getDatabase()
        const sessionId = uuidv4()
        const session = await jwtDecode(authSession.id_token)
        console.log('Create Session', session)

        // enrich with raw oidc session data
        session.oidc = authSession

        const { email, sub } = session
        const [identitySource, userIdentifier] = sub.split('|')
        const userData = {
            identitySource,
            userIdentifier,
            email,
            user_id: sub
        }

        const userProfile = await userService.get(userData)

        const { UserId, ...other } = userProfile

        session.UserId = UserId
        session.user = {}
        session.user.other = other

        const [sessionStore, created] = await sqldb.SessionStore.findOrCreate({
            where: {
                sessionId
            },
            defaults: {
                data: session,
                sessionId,
                UserId
            }
        })

        if (created) {
            console.log('Created SessionStore', sessionStore._id)
        }

        return { sessionId, session: sessionStore }
    }

    async get (sessionId) {
        const session = await getSession(sessionId)

        return session
    }

    async decodeJwt (jwt) {
        return jwtDecode(jwt)
    }

	async restoreSession (sessionId, authSession) {
		const rSession = await getSession(sessionId)

		if (!rSession) return { ok: false }

		const session = await jwtDecode(authSession.id_token)

		// enrich with raw oidc session data
		session.oidc = authSession

		const { email, sub } = session
		const [identitySource, userIdentifier] = sub.split('|')
		const userData = {
			identitySource,
			userIdentifier,
			email,
			user_id: sub
		}

		if (session.sub !== rSession.data.sub) {
			console.info(`Session is not for authed User ${session.sub} vs ${rSession.data.sub}`)
			return { ok: false }
		}

		const userProfile = await userService.get(userData)

		const { orgs, projects } = userProfile
		session.user = {}
		session.user.orgs = orgs || []
		session.user.projects = projects || []

		try {
			rSession.data = Object.assign(rSession.data, session)
			rSession.changed('data', true)

			await rSession.save()
			return { ok: true }
		} catch (e) {
			console.error(e.message)
			return { ok: false }
		}
	}
}

async function getSession (sessionId) {
    const sqldb = await db.getDatabase()
    const session = await sqldb.SessionStore.findOne({
        where: {
            sessionId
        }
    })

    return session
}

export default SessionService

src/lib/services/db.js

import _ from 'lodash'
import orm from '<<sequelize orm project>>'

const config = {
    sequelize: {
        // eslint-disable-next-line dot-notation
        sync: process.env['DB_SYNC'],
        // eslint-disable-next-line dot-notation
        syncForce: process.env['DB_SYNC_FORCE'],
        // eslint-disable-next-line dot-notation
        database: process.env['DB_DATABASE'] ,
        // eslint-disable-next-line dot-notation
        host: process.env['DB_HOST'] || '127.0.0.1',
        // eslint-disable-next-line dot-notation
        port: process.env['DB_PORT'],
        // eslint-disable-next-line dot-notation
        username: process.env['DB_USERNAME'],
        // eslint-disable-next-line dot-notation
        password: process.env['DB_PASSWORD'],
        // eslint-disable-next-line dot-notation
        dbDefault: process.env['DB_DEFAULT'] || 'postgres',
        dialectOptions: {
            // eslint-disable-next-line dot-notation
            ssl: process.env['DB_SSL'] === 'true'
        }
    }
}

const sqldb = orm(config)
class DatabaseService {
    async getDatabase () {
        return sqldb
    }
}
export default DatabaseService

src/lib/services/user.js

import _ from 'lodash'
import DB from './db'

const db = new DB()

class UserService {
    async get (userData) {
        const sqldb = await db.getDatabase()

        const [user, created] = await sqldb.User.findOrCreate({
            where: {
                email: userData.email
            },
            defaults: userData
        })

        if (!created) {
            if (!user.userIdentifier) {
                await updateUserAttributes(user, userData)
            }
            console.debug('Existing User')
            return {
                UserId: user._id
            }
        } else {
            console.debug('created new user', JSON.stringify(user, null, 2))
            return {
                UserId: user._id
            }
        }
    }
}

async function updateUserAttributes (user, aUser) {
    const identitySource = (aUser.identitySource) ? aUser.identitySource : aUser.identities[0].provider // TODO: should we always assume 0?
    const userIdentifier = (aUser.userIdentifier) ? aUser.userIdentifier : aUser.identities[0].user_id // TODO: should we always assume 0?

    user.identitySource = identitySource
    user.userIdentifier = userIdentifier
    user.user_id = aUser.user_id
    await user.save()
}

export default UserService

Contributing

We appreciate feedback and contribution to this repo! Before you get started, please see the following:

Contributions can be made to this library through PRs to fix issues, improve documentation or add features. Please fork this repo, create a well-named branch, and submit a PR with a complete template filled out.

Code changes in PRs should be accompanied by tests covering the changed or added functionality. Tests can be run for this library with:

npm install
npm test

When you're ready to push your changes, please run the lint command first:

npm run lint

Support + Feedback

Please use the Issues queue in this repo for questions and feedback.

What is Auth0?

Auth0 helps you to easily:

  • implement authentication with multiple identity providers, including social (e.g., Google, Facebook, Microsoft, LinkedIn, GitHub, Twitter, etc), or enterprise (e.g., Windows Azure AD, Google Apps, Active Directory, ADFS, SAML, etc.)
  • log in users with username/password databases, passwordless, or multi-factor authentication
  • link multiple user accounts together
  • generate signed JSON Web Tokens to authorize your API calls and flow the user identity securely
  • access demographics and analytics detailing how, when, and where users are logging in
  • enrich user profiles from other data sources using customizable JavaScript rules

Why Auth0?

License

This project is licensed under the MIT license. See the LICENSE file for more info.

Package Sidebar

Install

npm i sveltekit-openid-connect

Weekly Downloads

12

Version

2.0.1

License

MIT

Unpacked Size

316 kB

Total Files

25

Last publish

Collaborators

  • chrisogden