0

I'm familiar with Svelte but completely new to Sveltekit. I'm trying to build a Sveltekit app from scratch using AWS Cognito as the authorization tool without using AWS Amplify, using the amazon-cognito-identity-js sdk. I've got all the functionality working as far as login, registration, and verification, but I can't seem to get a handle on how to store the session data for the structure I've built.

I've been trying to translate the strategies from this tutorial, based in React, to Sveltekit -- (AWS Cognito + React JS Tutorial - Sessions and Logging out (2020) [Ep. 3]) https://www.youtube.com/watch?v=R-3uXlTudSQ and this REPL to understand using context in Svelte ([AD] Combining the Context API with Stores) https://svelte.dev/repl/7df82f6174b8408285a1ea0735cf2ff0

To elaborate, I've got my structure like so (only important parts shown):

src
|
|-- components
    |-- ...
    |-- status.svelte
|-- routes
    |
    |-- dashboard
    |-- onboarding
        |-- __layout.reset.svelte
        |-- login.svelte
        |-- signup.svelte
        |-- verify.svelte
        |-- ...
    |-- settings
    |-- __layout.svelte
    |-- index.svelte
|-- styles
|-- utils
    |-- cognitoTools.ts
    |-- stores.ts

I wanted to have a separate path for my onboarding pages, hence the sub-folder. My cognito-based functions reside within cognitoTools.ts. An example of a few functions look like:

export const Pool = new CognitoUserPool(poolData);

export const User = (Username: string): any => new CognitoUser({ Username, Pool });

export const Login = (username: string, password: string): any => {
    return new Promise ((resolve, reject) => User(username).authenticateUser(CognitoAuthDetails(username, password), {
        onSuccess: function(result) {
            console.log('CogTools login success result: ', result);
            resolve(result)
        },
        onFailure: function(err) {
            console.error('CogTools login err: ', err);
            reject(err)
        }
    }))
}

I'm able to then use the methods freely anywhere:

// src/routes/onboarding/login.svelte
import { Login, Pool } from '@utils/cognitoTools'
import { setContext, getContext } from 'svelte'

let username;
let password;
let session = writeable({});
let currentSession;

// Setting our userSession store to variable that will be updated
$: userSession.set(currentSession);

// Attempt to retrieve getSession func defined from wrapper component __layout.svelte
const getSession = getContext('getSession');

const handleSubmit = async (event) => {
    event.preventDefault()
        
    Login(username, password, rememberDevice)
    .then(() => {
        getSession().then((session) => {
            // userSession.set(session);
            currentSession = session;
            setContext('currentSession', userSession);
        })
    })
}
...

// src/routes/__layout.svelte
    ...
    const getSession = async () => {
        return await new Promise((resolve, reject) => {
            const user = Pool.getCurrentUser();
            
            if (user) {
                user.getSession((err, session) => {
                    console.log('User get session result: ', (err ? err : session));
                    err ? reject() : resolve(session);
                });
            } else {
                console.log('get session no user found');
                reject();
            }
        })
    }

    setContext('getSession', getSession)

Then, I've been trying to retrieve the session in src/components/status.svelte or src/routes/__layout.svelte (as I think I understand context has to be set in the top level components, and can then be used by indirect child components) to check if the context was set correctly.

Something like:


let status = false;

const user = getContext('currentSession');

status = user ? true : false;

I'm running in circles and I know I'm so close to the answer. How do I use reactive context with my current file structure to accomplish this?

LionOnTheWeb
  • 326
  • 2
  • 16

2 Answers2

2

I don't know much about the sdk, so I can't help you with your code above. But I also built an app that uses cognito for auth, and I can share some snippets on how to do it from scratch.

  1. Implement a login form. I have my basic app skeleton (navbar, footer, main slot) in _layout.svelte, and it is configured to show the Login.svelte component and not the main slot if the user is not logged in.

file: __layout.svelte

 <script context="module">
    export async function load({ session }) {
        return {
            props: {
                user: session.user,
            }
        }
    }
</script>

<script>
    import "../app.css";
    import Login from "$components/Login.svelte";
    export let user
</script>

<svelte:head>
    <title>title</title>
</svelte:head>

{#if user}
<header>
  
</header>

<main>
    <slot />
</main>

{:else}
    <Login />
{/if}

file: Login.svelte

<form action="/" method="GET">
    <input type="hidden" name="action" value="signin" />
    <button type="submit" >Sign in</button>
</form>
  1. Handle the login I choose to do this as a svelte endpoint paired with the index. It keeps the routing super simple. You could do separate login.js and logout.js endpoints if you prefer. Just change your url in the form above.

file: index.js

import { v4 as uuid } from '@lukeed/uuid'
import db from '$lib/db'

const domain = import.meta.env.VITE_COGNITO_DOMAIN
const clientId = import.meta.env.VITE_COGNITO_CLIENT_ID
const redirectUri = import.meta.env.VITE_COGNITO_REDIRECT_URI
const logoutUri = import.meta.env.VITE_COGNITO_LOGOUT_URI

export const get = async (event) => {
    const action = event.url.searchParams.get('action')
    if (action === 'signin') {
        // Hard to guess random string.  Used to protect against forgery attacks.
        // Should add check in callback that the state matches to prevent forgery
        const state = uuid() 
        return {
        status: 302,
            headers: {
                location: `https://${domain}/oauth2/authorize?response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}&scope=openid+email+profile&state=${state}`,
            },
        }
    }
    if (action === 'signout') {   
        // delete this session from database
        if (event.locals.session_id) {
            await db.sessions.deleteMany({
                where: { session_id: event.locals.session_id }
            })
        }
        return {
            status: 302,
            headers: {
                location: `https://${domain}/logout?client_id=${clientId}&logout_uri=${logoutUri}`
            }
        }
    }
    return {}
}
  1. Handle the callback from AWS cognito. Again, I have the callback simply point to the root url. All authentication for me is handled at "/". The heavy lifting is done by hooks.js. This is your SK middleware. It for me is the sole arbiter of the user's authentication state, just because I like to keep it easy for me to understand.

file: hooks.js

import { v4 as uuid } from '@lukeed/uuid'
import cookie from 'cookie'
import db from '$lib/db'

const domain = import.meta.env.VITE_COGNITO_DOMAIN
const clientId = import.meta.env.VITE_COGNITO_CLIENT_ID
const clientSecret = import.meta.env.VITE_COGNITO_CLIENT_SECRET
const redirectUri = import.meta.env.VITE_COGNITO_REDIRECT_URI
const tokenUrl = `https://${domain}/oauth2/token`
const profileUrl = `https://${domain}/oauth2/userInfo`

export const handle = async ({ event, resolve }) => {
    const cookies = cookie.parse(event.request.headers.get('cookie') || '')
    event.locals.session_id = cookies.session_id // this will be overwritten by a new session_id if this is a callback

    if (event.locals.session_id) {
        // We have a session cookie, check to see if it is valid.  
        // Do this by checking against your session db or session store or whatever
        // If not valid, or if it is expired, set event.locals.session_id to null 
        // This will cause the cookie to be deleted below
        // In this example, we just assume it's valid
    } 
    
    if ( (!event.locals.session_id) && event.url.searchParams.get('code') && event.url.searchParams.get('state') ) {
        // No valid session cookie, check to see if this is a callback
        const code = event.url.searchParams.get('code')
        const state = event.url.searchParams.get('state')
        // Change this to try, catch for error handling
        const token = await getToken(code, state)
        if (token != null) {
            let cognitoUser = await getUser(token)
            event.locals.session_id = uuid()
            // Add the value to the db
            await db.sessions.create({
                data: { 
                    session_id: event.locals.session_id,
                    user_id: cognitoUser.username,
                    created: Date() 
                },
            })
            let user = await db.users.findUnique({
                where: {
                    user_id: cognitoUser.username,
                }
            })
            event.locals.user = user
            event.locals.authorized = true
        }
    }
    
    const response = await resolve(event);

    // This will delete the cookie if event.locals.session_id is null
    response.headers.set(
        'set-cookie',
        cookie.serialize('session_id', event.locals.session_id, {
            path: '/',
            httpOnly: true,
            sameSite: 'strict',
            maxAge: 60 * 60 * 24 * 7, // one week
        })
    )
    
    return response;
}

export async function getSession(event) {
    return {
        user: event.locals.user,
    }               
}

const getToken = async (code, state) => {
    let authorization = Buffer.from(`${clientId}:${clientSecret}`).toString('base64')
    const res = await fetch(tokenUrl, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
            Authorization: `Basic ${authorization}`,
        },
        body: `grant_type=authorization_code&client_id=${clientId}&code=${code}&state=${state}&redirect_uri=${redirectUri}`
    })
    if (res.ok) {
        const data = await res.json()
        return data.access_token
    } else {
        return null
    }
}

const getUser = async (token) => {
    const res = await fetch(profileUrl, {
        headers: {
            Authorization: `Bearer ${token}`,
        },
    })
    if (res.ok) {
        return res.json()
    } else {
        return null
    }
}
  1. Lastly, getting the auth state. In a client-side route, this is done via the page load function. I put this in __layout to have it available on all .svelte routes. You can see it at the top of the __layout file above. For SSR endpoints, you can just access event.locals directly.

NOTE: All of the env vars are set in your .env file and will be imported by vite. This only happens when you start your app, so if you add/change them, you need to restart it.

I don't know if this helps at all since it is so different from your app structure, but maybe you will get some ideas from it.

Pevey
  • 21
  • 3
1

I don't know what the problem is you run into exactly, but one thing that stands out to me is that you are calling setContext when it's "too" late. You can only call getContext/setContext within component initialization. See this answer for more details: Is there a way to use svelte getContext etc. svelte functions in Typescript files?

If this is the culprit and you are looking for a way how to get the session then: Use context in combination with stores:

<!-- setting the session -->
<script>
  // ...
  const session = writable(null);
  setContext('currentSession', session);
  // ...
  Login...then(() => ...session.set(session));
</script>

<!-- setting the session -->
<script>
  // ..
  const user = getContext('currentSession');
  // ..
  status = $user ? true : false;
</script>

Another thing that stands out to me - but is too long/vague for a StackOverflow answer - is that you are not using SvelteKit's features to achieve this behavior. You could look into load and use stuff in __layout to pass the session down to all children. I'm no sure if this is of any advantage for you though since you are maybe planning to do a SPA anyway and therefore don't need such SvelteKit features.

dummdidumm
  • 4,828
  • 15
  • 26
  • This is very helpful, thank you. I was also confused as using a Svelte store, stores the sessions in local storage (at least that's what I'm seeing) instead of a cookie, which I'm used to sessions being stored as cookies. I was looking for the best Sveltekit way to implement a persisting session, but your answer does provide further clarity. – LionOnTheWeb Jan 25 '22 at 08:10