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.

/sveltekit-openid-connect/

    Package Sidebar

    Install

    npm i sveltekit-openid-connect

    Weekly Downloads

    33

    Version

    2.0.1

    License

    MIT

    Unpacked Size

    316 kB

    Total Files

    25

    Last publish

    Collaborators

    • chrisogden