├── .npmrc ├── static └── favicon.png ├── src ├── lib │ ├── constants.ts │ ├── index.js │ ├── client.ts │ └── server.ts ├── routes │ ├── bearer-token │ │ ├── api │ │ │ └── +server.ts │ │ └── +page.svelte │ ├── authenticated │ │ ├── +page.server.ts │ │ └── +page.svelte │ ├── +layout.server.ts │ ├── server-api-call │ │ ├── +page.svelte │ │ └── +page.server.ts │ ├── signin │ │ └── +page.server.ts │ ├── +page.svelte │ ├── client-api-call │ │ └── +page.svelte │ └── +layout.svelte ├── app.html ├── app.d.ts └── hooks.server.ts ├── .gitignore ├── vite.config.js ├── .prettierrc ├── .eslintignore ├── .prettierignore ├── playwright.config.ts ├── svelte.config.js ├── .github └── workflows │ ├── pr.yml │ ├── test.yml │ └── release.yml ├── tsconfig.json ├── test ├── client.test.js └── utils.js ├── .eslintrc.cjs ├── package.json ├── README.md └── CHANGELOG.md /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HalfdanJ/svelte-google-auth/HEAD/static/favicon.png -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const AUTH_CODE_CALLBACK_URL = '/_auth/callback'; 2 | export const AUTH_SIGNOUT_URL = '/_auth/signout'; 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | /client_secret.json 10 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | 3 | /** @type {import('vite').UserConfig} */ 4 | const config = { 5 | plugins: [sveltekit()] 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "pluginSearchDirs": ["."], 7 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 8 | } 9 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | CHANGELOG.md -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from '@playwright/test'; 2 | 3 | const config: PlaywrightTestConfig = { 4 | webServer: { 5 | command: 'npm run dev -- --port 5174', 6 | port: 5174 7 | } 8 | }; 9 | 10 | export default config; 11 | -------------------------------------------------------------------------------- /src/routes/bearer-token/api/+server.ts: -------------------------------------------------------------------------------- 1 | import { getAuthLocals } from '$lib/server.js'; 2 | import { json } from '@sveltejs/kit'; 3 | import type { RequestHandler } from './$types.js'; 4 | 5 | export const GET: RequestHandler = async ({ locals }) => { 6 | return json(getAuthLocals(locals).user); 7 | }; 8 | -------------------------------------------------------------------------------- /src/routes/authenticated/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { isSignedIn } from '$lib/server.js'; 2 | import { error } from '@sveltejs/kit'; 3 | import type { PageServerLoad } from './$types.js'; 4 | 5 | export const load: PageServerLoad = ({ locals }) => { 6 | if (!isSignedIn(locals)) throw error(403, 'Not signed in'); 7 | }; 8 | -------------------------------------------------------------------------------- /src/lib/index.js: -------------------------------------------------------------------------------- 1 | // Reexport your entry components here 2 | 3 | // export { getClientId, getGapiClient, signIn, signOut, user } from './client.js'; 4 | export { 5 | getAuthLocals, 6 | getOAuth2Client, 7 | hydrateAuth, 8 | isSignedIn, 9 | generateAuthUrl, 10 | SvelteGoogleAuthHook 11 | } from './server.js'; 12 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // See https://kit.svelte.dev/docs/types#app 4 | // for information about these interfaces 5 | // and what to do when importing types 6 | declare namespace App { 7 | // interface Locals {} 8 | // interface Platform {} 9 | // interface PrivateEnv {} 10 | // interface PublicEnv {} 11 | } 12 | -------------------------------------------------------------------------------- /src/hooks.server.ts: -------------------------------------------------------------------------------- 1 | import { SvelteGoogleAuthHook } from '$lib/server.js'; 2 | import type { Handle } from '@sveltejs/kit'; 3 | import client_secret from '../client_secret.json'; 4 | 5 | const auth = new SvelteGoogleAuthHook(client_secret.web); 6 | 7 | export const handle: Handle = async ({ event, resolve }) => { 8 | return await auth.handleAuth({ event, resolve }); 9 | }; 10 | -------------------------------------------------------------------------------- /src/routes/+layout.server.ts: -------------------------------------------------------------------------------- 1 | import { hydrateAuth } from '$lib/server.js'; 2 | import type { LayoutServerLoad } from './$types.js'; 3 | 4 | export const load: LayoutServerLoad = ({ locals }) => { 5 | // By calling hydateAuth, certain variables from locals are parsed to the client 6 | // allowing the client to access the user information and the client_id for login 7 | return { ...hydrateAuth(locals) }; 8 | }; 9 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-auto'; 2 | import preprocess from 'svelte-preprocess'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://github.com/sveltejs/svelte-preprocess 7 | // for more information about preprocessors 8 | preprocess: preprocess(), 9 | 10 | kit: { 11 | adapter: adapter() 12 | } 13 | }; 14 | 15 | export default config; 16 | -------------------------------------------------------------------------------- /src/routes/server-api-call/+page.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |

Example of api call from the server

7 |

When loaded, the server fetches data from the api, and injects it into the page data.

8 | 9 | {#if data.auth.user} 10 | Next event: {data.calendarEvent?.summary} 11 | {:else} 12 | Not signed in 13 | {/if} 14 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request Linter 2 | on: 3 | pull_request: 4 | types: 5 | - opened 6 | - edited 7 | - reopened 8 | jobs: 9 | lint-pr: 10 | name: Lint pull request title 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Lint pull request title 14 | uses: jef/conventional-commits-pr-action@v1 15 | with: 16 | token: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /src/routes/authenticated/+page.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |

Example of page that requires authentication.

8 | 9 |

You can view this page right now because you are authenticate as {data.auth.user?.name}.

10 | 11 | 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "moduleResolution": "NodeNext", 13 | "types": ["gapi", "google.accounts", "gapi.calendar"] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/routes/signin/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { generateAuthUrl } from '$lib/server.js'; 2 | import { redirect } from '@sveltejs/kit'; 3 | import type { PageServerLoad } from './$types.js'; 4 | 5 | export const load: PageServerLoad = ({ url, locals }) => { 6 | throw redirect( 7 | 302, 8 | generateAuthUrl( 9 | locals, 10 | url, 11 | ['openid', 'profile', 'email', 'https://www.googleapis.com/auth/calendar.readonly'], 12 | '/' 13 | ) 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /test/client.test.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { expect } from '@playwright/test'; 3 | import { test } from './utils.js'; 4 | 5 | test.describe('auth', () => { 6 | test('page signed out by default', async ({ page }) => { 7 | await page.goto('/'); 8 | const signInButton = page.locator('text=Sign in'); 9 | await expect(signInButton).toHaveCount(1); 10 | }); 11 | 12 | test('authenticated route throws 403', async ({ page }) => { 13 | await page.goto('/authenticated'); 14 | expect(await page.textContent('h1')).toBe('403'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], 5 | plugins: ['svelte3', '@typescript-eslint'], 6 | ignorePatterns: ['*.cjs'], 7 | overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }], 8 | settings: { 9 | 'svelte3/typescript': () => require('typescript') 10 | }, 11 | parserOptions: { 12 | sourceType: 'module', 13 | ecmaVersion: 2020 14 | }, 15 | env: { 16 | browser: true, 17 | es2017: true, 18 | node: true 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 |

Svelte Google Authorization Example

2 | 3 |

Examples:

4 |

5 | /authenticated - Route is only available when user is logged in, 6 | routes to the error page in other situations 7 |

8 | 9 |

10 | /client-api-call - Example showing how to call api calls from client 11 | side 12 |

13 |

14 | /server-api-call - Example showing how to call api calls from server 15 | side 16 |

17 |

18 | /signin - Example of redirecting user to sign in prompt instead of showing popup 19 |

20 | -------------------------------------------------------------------------------- /src/routes/bearer-token/+page.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 |

Example of requesting authentication using bearer token.

20 | 21 | {#await apiDataPromise} 22 | Fetching... 23 | {:then apiData} 24 |
25 | {JSON.stringify(apiData, null, 2)}
26 | 	
27 | {/await} 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/routes/server-api-call/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { getOAuth2Client, isSignedIn } from '$lib/server.js'; 2 | import type { OAuth2Client } from 'google-auth-library'; 3 | import { google } from 'googleapis'; 4 | import type { PageServerLoad } from './$types.js'; 5 | 6 | async function fetchCalendar(auth: OAuth2Client) { 7 | const calendar = google.calendar({ version: 'v3', auth }); 8 | const res = await calendar.events.list({ 9 | calendarId: 'primary', 10 | timeMin: new Date().toISOString(), 11 | maxResults: 1, 12 | singleEvents: true, 13 | orderBy: 'startTime' 14 | }); 15 | return res.data.items?.[0]; 16 | } 17 | 18 | export const load: PageServerLoad = async ({ locals }) => { 19 | if (isSignedIn(locals)) { 20 | // Get an authenticated oauth2 client 21 | const client = getOAuth2Client(locals); 22 | // Fetch calendar events using the client 23 | const calendarEvent = await fetchCalendar(client); 24 | 25 | return { 26 | calendarEvent 27 | }; 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /src/routes/client-api-call/+page.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 |

Example of api call from the browser

28 |

29 | When loaded, a gapi client is created, injected with the access token generated on the server. 30 | This gapi client is then used to fetch from calendar api. 31 |

32 | 33 | {#if data.auth.user} 34 | {#await nextEvent} 35 | Fetching... 36 | {:then _nextEvent} 37 | Next event: {_nextEvent?.summary} 38 | {/await} 39 | {:else} 40 | Not signed in 41 | {/if} 42 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | schedule: 9 | - cron: '00 20 * * *' 10 | 11 | env: 12 | CI: true 13 | 14 | jobs: 15 | lint: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: actions/setup-node@v3 20 | with: 21 | node-version: '18' 22 | - run: npm i 23 | - run: npm run lint 24 | # test: 25 | # runs-on: ubuntu-latest 26 | # timeout-minutes: 10 27 | # steps: 28 | # - uses: actions/checkout@v3 29 | # - uses: actions/setup-node@v3 30 | # with: 31 | # node-version: '18' 32 | # - run: npm i 33 | # - run: npx playwright install-deps 34 | # - run: npx playwright install 35 | # - name: Run Playwright Tests 36 | # run: npm run test 37 | # - name: Archive test results 38 | # if: failure() 39 | # shell: bash 40 | # run: find packages -type d -name test-results -not -empty | tar -czf test-results.tar.gz --files-from=- 41 | # - name: Upload test results 42 | # if: failure() 43 | # uses: actions/upload-artifact@v3 44 | # with: 45 | # retention-days: 3 46 | # name: test-failure-${{ github.run_id }} 47 | # path: test-results.tar.gz 48 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | env: 13 | HUSKY: 0 14 | steps: 15 | - uses: GoogleCloudPlatform/release-please-action@v2 16 | id: release 17 | with: 18 | release-type: node 19 | bump-minor-pre-major: true # remove this to enable breaking changes causing 1.0.0 tag 20 | prerelease: true 21 | 22 | # The logic below handles the npm publication: 23 | # The if statements ensure that a publication only occurs when a new release is created 24 | - name: Checkout 25 | uses: actions/checkout@v2 26 | with: 27 | fetch-depth: 0 28 | persist-credentials: false 29 | if: ${{ steps.release.outputs.release_created }} 30 | 31 | - uses: actions/setup-node@v1 32 | with: 33 | node-version: 16 34 | registry-url: 'https://registry.npmjs.org' 35 | if: ${{ steps.release.outputs.release_created }} 36 | 37 | - run: npm install 38 | if: ${{ steps.release.outputs.release_created }} 39 | 40 | - run: npm run build 41 | if: ${{ steps.release.outputs.release_created }} 42 | 43 | - run: npm publish 44 | working-directory: package 45 | env: 46 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 47 | if: ${{ steps.release.outputs.release_created }} 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-google-auth", 3 | "version": "0.7.2", 4 | "author": "Jonas Jongejan ", 5 | "license": "apache-2.0", 6 | "keywords": [ 7 | "svelte", 8 | "sveltekit", 9 | "oauth2" 10 | ], 11 | "scripts": { 12 | "dev": "vite dev", 13 | "build": "svelte-kit sync && svelte-package", 14 | "test": "playwright test", 15 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 16 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 17 | "lint": "prettier --check . && eslint .", 18 | "format": "prettier --write ." 19 | }, 20 | "devDependencies": { 21 | "@playwright/test": "^1.29.2", 22 | "@sveltejs/adapter-auto": "next", 23 | "@sveltejs/kit": "next", 24 | "@sveltejs/package": "next", 25 | "@types/cookie": "^0.5.1", 26 | "@types/gapi": "^0.0.43", 27 | "@types/gapi.calendar": "^3.0.6", 28 | "@types/google.accounts": "^0.0.5", 29 | "@types/jsonwebtoken": "^9.0.1", 30 | "@typescript-eslint/eslint-plugin": "^5.48.2", 31 | "@typescript-eslint/parser": "^5.48.2", 32 | "eslint": "^8.32.0", 33 | "eslint-config-prettier": "^8.6.0", 34 | "eslint-plugin-svelte3": "^4.0.0", 35 | "prettier": "^2.8.3", 36 | "prettier-plugin-svelte": "^2.9.0", 37 | "svelte": "^3.44.0", 38 | "svelte-check": "^2.7.1", 39 | "svelte-preprocess": "^4.10.6", 40 | "tslib": "^2.3.1", 41 | "typescript": "^4.7.4", 42 | "vite": "^3.0.0" 43 | }, 44 | "type": "module", 45 | "dependencies": { 46 | "cookie": "^0.5.0", 47 | "jsonwebtoken": "^9.0.0", 48 | "googleapis": "^110.0.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 |
13 | {#if data.auth.user} 14 |

15 | Signed in as {data.auth.user.name} ({data.auth.user.email}) 16 | profile 17 |

18 |

19 | 20 |

21 | {:else} 22 |

23 | 32 |

33 | {/if} 34 |
35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 63 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import { test as base } from '@playwright/test'; 3 | 4 | export const test = base.extend({ 5 | app: async ({ page }, use) => { 6 | // these are assumed to have been put in the global scope by the layout 7 | use({ 8 | /** 9 | * @param {string} url 10 | * @param {{ replaceState?: boolean }} opts 11 | * @returns {Promise} 12 | */ 13 | goto: (url, opts) => 14 | page.evaluate( 15 | (/** @type {{ url: string, opts: { replaceState?: boolean } }} */ { url, opts }) => 16 | goto(url, opts), 17 | { url, opts } 18 | ), 19 | 20 | /** 21 | * @param {string} url 22 | * @returns {Promise} 23 | */ 24 | invalidate: (url) => page.evaluate((/** @type {string} */ url) => invalidate(url), url), 25 | 26 | /** 27 | * @param {(url: URL) => void | boolean | Promise} fn 28 | * @returns {Promise} 29 | */ 30 | beforeNavigate: (fn) => 31 | page.evaluate((/** @type {(url: URL) => any} */ fn) => beforeNavigate(fn), fn), 32 | 33 | /** 34 | * @returns {Promise} 35 | */ 36 | afterNavigate: () => page.evaluate(() => afterNavigate(() => undefined)), 37 | 38 | /** 39 | * @param {string} url 40 | * @returns {Promise} 41 | */ 42 | prefetch: (url) => page.evaluate((/** @type {string} */ url) => prefetch(url), url), 43 | 44 | /** 45 | * @param {string[]} [urls] 46 | * @returns {Promise} 47 | */ 48 | prefetchRoutes: (urls) => page.evaluate((urls) => prefetchRoutes(urls), urls) 49 | }); 50 | }, 51 | 52 | clicknav: async ({ page, javaScriptEnabled }, use) => { 53 | /** 54 | * @param {string} selector 55 | * @param {{ timeout: number }} options 56 | */ 57 | async function clicknav(selector, options) { 58 | if (javaScriptEnabled) { 59 | await Promise.all([page.waitForNavigation(options), page.click(selector)]); 60 | } else { 61 | await page.click(selector); 62 | } 63 | } 64 | 65 | use(clicknav); 66 | }, 67 | 68 | in_view: async ({ page }, use) => { 69 | /** @param {string} selector */ 70 | async function in_view(selector) { 71 | const box = await page.locator(selector).boundingBox(); 72 | const view = await page.viewportSize(); 73 | return box && view && box.y < view.height && box.y + box.height > 0; 74 | } 75 | 76 | use(in_view); 77 | }, 78 | 79 | page: async ({ page, javaScriptEnabled }, use) => { 80 | if (javaScriptEnabled) { 81 | page.addInitScript({ 82 | content: ` 83 | addEventListener('sveltekit:start', () => { 84 | document.body.classList.add('started'); 85 | }); 86 | ` 87 | }); 88 | } 89 | 90 | // automatically wait for kit started event after navigation functions if js is enabled 91 | const page_navigation_functions = ['goto', 'goBack', 'reload']; 92 | page_navigation_functions.forEach((fn) => { 93 | const page_fn = page[fn]; 94 | if (!page_fn) { 95 | throw new Error(`function does not exist on page: ${fn}`); 96 | } 97 | 98 | page[fn] = async function (...args) { 99 | const res = await page_fn.call(page, ...args); 100 | if (javaScriptEnabled) { 101 | await page.waitForSelector('body.started', { timeout: 5000 }); 102 | } 103 | return res; 104 | }; 105 | }); 106 | 107 | await use(page); 108 | } 109 | }); 110 | -------------------------------------------------------------------------------- /src/lib/client.ts: -------------------------------------------------------------------------------- 1 | import type { invalidateAll } from '$app/navigation'; 2 | import { AUTH_CODE_CALLBACK_URL, AUTH_SIGNOUT_URL } from './constants.js'; 3 | import type { AuthClientData } from './server.js'; 4 | 5 | interface AuthContext { 6 | getData: () => AuthClientData; 7 | invalidateAll: typeof invalidateAll; 8 | } 9 | 10 | let context: AuthContext | undefined = undefined; 11 | 12 | function getAuthContext(): AuthContext { 13 | if (!context) 14 | throw new Error( 15 | 'svelte-google-auth context not defined. Did you forget to call `initialize(data)` +layout.svelte?' 16 | ); 17 | return context; 18 | } 19 | 20 | export async function initialize( 21 | data: { auth: AuthClientData }, 22 | _invalidateAll: typeof invalidateAll 23 | ) { 24 | context = { 25 | getData: () => data.auth, 26 | invalidateAll: () => _invalidateAll() 27 | }; 28 | } 29 | 30 | /** 31 | * Prompt user to sign in using google auth 32 | */ 33 | export async function signIn(scopes: string[] = ['openid', 'profile', 'email']) { 34 | await loadGIS(); 35 | 36 | const client_id = await getClientId(); 37 | 38 | return new Promise((resolve, reject) => { 39 | const client = google.accounts.oauth2.initCodeClient({ 40 | client_id, 41 | scope: scopes.join(' '), 42 | ux_mode: 'popup', 43 | 44 | callback: (response: any) => { 45 | const { code, scope } = response; 46 | const xhr = new XMLHttpRequest(); 47 | xhr.open('POST', AUTH_CODE_CALLBACK_URL, true); 48 | xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); 49 | // Set custom header for CRSF 50 | xhr.setRequestHeader('X-Requested-With', 'XmlHttpRequest'); 51 | xhr.onload = async function () { 52 | console.log('Auth code response: ' + xhr.responseText); 53 | await getAuthContext().invalidateAll(); 54 | resolve(); 55 | }; 56 | xhr.onerror = reject; 57 | xhr.onabort = reject; 58 | xhr.send('code=' + code); 59 | } 60 | }); 61 | client.requestCode(); 62 | }); 63 | } 64 | 65 | /** Sign user out */ 66 | export async function signOut() { 67 | await fetch(AUTH_SIGNOUT_URL, { method: 'POST' }); 68 | if (window.gapi) gapi.client.setToken({ access_token: '' }); 69 | 70 | await getAuthContext().invalidateAll(); 71 | } 72 | 73 | let _gapiClientInitialized = false; 74 | /** Returns initialized gapi client */ 75 | export async function getGapiClient( 76 | args: { 77 | apiKey?: string | undefined; 78 | discoveryDocs?: string[] | undefined; 79 | } = {} 80 | ) { 81 | if (!_gapiClientInitialized) { 82 | await loadGAPI(); 83 | await new Promise((resolve, reject) => { 84 | gapi.load('client', { callback: resolve, onerror: reject }); 85 | }); 86 | await gapi.client.init({ ...args }); 87 | _gapiClientInitialized = true; 88 | } 89 | 90 | const access_token = getAuthContext().getData().access_token; 91 | if (access_token) gapi.client.setToken({ access_token }); 92 | return gapi.client; 93 | } 94 | 95 | async function injectScript(src: string) { 96 | return new Promise((resolve, reject) => { 97 | // GIS Library, loads itself onto the window as 'google' 98 | const googscr = document.createElement('script'); 99 | googscr.type = 'text/javascript'; 100 | googscr.src = src; 101 | googscr.defer = true; 102 | googscr.onload = resolve; 103 | googscr.onerror = reject; 104 | document.head.appendChild(googscr); 105 | }); 106 | } 107 | 108 | export async function loadGIS() { 109 | if (window.google?.accounts?.oauth2) return; 110 | return injectScript('https://accounts.google.com/gsi/client'); 111 | } 112 | export async function loadGAPI() { 113 | if (window.gapi) return; 114 | return injectScript('https://apis.google.com/js/api.js'); 115 | } 116 | 117 | export async function getClientId() { 118 | const data = getAuthContext().getData(); 119 | 120 | const clientId = data.client_id as string; 121 | if (!clientId) { 122 | throw new Error( 123 | 'svelte-google-auth could not find required data from page data. \nDid you remember to return `hydrateAuth(locals)` in +layout.server.ts?' 124 | ); 125 | } 126 | return clientId; 127 | } 128 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # svelte-google-auth 2 | 3 | [![NPM version](https://img.shields.io/npm/v/svelte-google-auth.svg?style=flat)](https://www.npmjs.com/package/svelte-google-auth) 4 | [![stability-beta](https://img.shields.io/badge/stability-beta-33bbff.svg)](https://github.com/mkenney/software-guides/blob/master/STABILITY-BADGES.md#beta) 5 | [![SvelteKit](https://img.shields.io/badge/Works%20with-SvelteKit-ff3e00.svg)](https://kit.svelte.dev/) 6 | 7 | This library provides an easy-to-use solution for Google authentication in SvelteKit, facilitating interaction with Google Identity Services and cookie storage for authenticated users in subsequent visits. It also allows authorized Google API calls from the client and server sides. 8 | 9 | ## How it works 10 | 11 | The library follows the official guide for [oauth2 code model](https://developers.google.com/identity/oauth2/web/guides/use-code-model#redirect-mode). 12 | 13 | 1. User authenticates with the site in a popup 14 | 2. Popup responds with a code that gets sent to the backend 15 | 3. Backend converts the code to tokens (both an access token and refresh tokens) 16 | 4. Tokens get signed into a jwt httpOnly cookie, making every subsequent call to the backend authenticated 17 | 5. Library returns the authenticated user to the client using [page data](https://kit.svelte.dev/docs/load) 18 | 19 | ## Getting started 20 | 21 | ### Install 22 | 23 | ```bash 24 | npm i svelte-google-auth 25 | ``` 26 | 27 | ### Credentials 28 | 29 | Create an [OAuth2 Client Credentials](https://developers.google.com/identity/protocols/oauth2/web-server#creatingcred) in Google Cloud. Store the JSON file in your project but avoid committing it to Git. The following Authorized redirect URIs and Authorized JavaScript origins must be added: 30 | 31 | - Authorized JavaScript origins: `http://localhost:5173` 32 | - Authorized redirect URIs: `http://localhost:5173/_auth/callback` 33 | 34 | ### Hooks 35 | 36 | In `src/hooks.server.(js|ts)`, initialize the authentication hook. 37 | 38 | ```ts 39 | import { SvelteGoogleAuthHook } from 'svelte-google-auth/server'; 40 | import type { Handle } from '@sveltejs/kit'; 41 | 42 | // Import client credentials from json file 43 | import client_secret from '../client_secret.json'; 44 | 45 | const auth = new SvelteGoogleAuthHook(client_secret.web); 46 | 47 | export const handle: Handle = async ({ event, resolve }) => { 48 | return await auth.handleAuth({ event, resolve }); 49 | }; 50 | ``` 51 | 52 | ### +layout.server 53 | 54 | In the `src/routes/+layout.server.(js|ts)` file, create the following `load` function: 55 | 56 | ```ts 57 | import { hydrateAuth } from 'svelte-google-auth/server'; 58 | import type { LayoutServerLoad } from './$types.js'; 59 | 60 | export const load: LayoutServerLoad = ({ locals }) => { 61 | // By calling hydrateAuth, certain variables from locals are parsed to the client 62 | // allowing the client to access the user information and the client_id for login 63 | return { ...hydrateAuth(locals) }; 64 | }; 65 | ``` 66 | 67 | To force a user to sign in, you can redirect them to the login page as shown in the following updated `load` function: 68 | 69 | ```ts 70 | import { hydrateAuth } from 'svelte-google-auth/server'; 71 | import type { LayoutServerLoad } from './$types.js'; 72 | 73 | const SCOPES = ['openid', 'profile', 'email']; 74 | 75 | export const load: LayoutServerLoad = ({ locals, url }) => { 76 | if (!isSignedIn(locals)) { 77 | throw redirect(302, generateAuthUrl(locals, url, SCOPES, url.pathname)); 78 | } 79 | // By calling hydateAuth, certain variables from locals are parsed to the client 80 | // allowing the client to access the user information and the client_id for login 81 | return { ...hydrateAuth(locals) }; 82 | }; 83 | ``` 84 | 85 | ### Page 86 | 87 | You can now use the library on any page/layout like this 88 | 89 | ```svelte 90 | 98 | 99 | {data.auth.user?.name} 100 | 101 | 102 | ``` 103 | 104 | ## Example 105 | 106 | Check out the [example](/src/routes) to see how the API can be used. Run `npm run dev` to run it locally. 107 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### [0.7.2](https://www.github.com/HalfdanJ/svelte-google-auth/compare/v0.7.1...v0.7.2) (2023-01-17) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * Update vulnerability of packages ([#32](https://www.github.com/HalfdanJ/svelte-google-auth/issues/32)) ([7ba4729](https://www.github.com/HalfdanJ/svelte-google-auth/commit/7ba4729625cc4bc8d84b09208a9d505aad2069d3)) 9 | 10 | ### [0.7.1](https://www.github.com/HalfdanJ/svelte-google-auth/compare/v0.7.0...v0.7.1) (2023-01-17) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * Correct error handling with bearer tokens ([#30](https://www.github.com/HalfdanJ/svelte-google-auth/issues/30)) ([4ba03a7](https://www.github.com/HalfdanJ/svelte-google-auth/commit/4ba03a7e2f7a61cca4466be7cd8824bcf67a5caa)) 16 | 17 | ## [0.7.0](https://www.github.com/HalfdanJ/svelte-google-auth/compare/v0.6.2...v0.7.0) (2023-01-15) 18 | 19 | 20 | ### Features 21 | 22 | * Enable requests that contain bearer token ([#28](https://www.github.com/HalfdanJ/svelte-google-auth/issues/28)) ([84f46bc](https://www.github.com/HalfdanJ/svelte-google-auth/commit/84f46bc675d57ee3cd25d0c359db6488844943e1)) 23 | 24 | ### [0.6.2](https://www.github.com/HalfdanJ/svelte-google-auth/compare/v0.6.1...v0.6.2) (2022-12-09) 25 | 26 | 27 | ### Bug Fixes 28 | 29 | * Add try catch around refresh token handling ([#26](https://www.github.com/HalfdanJ/svelte-google-auth/issues/26)) ([ff97d2b](https://www.github.com/HalfdanJ/svelte-google-auth/commit/ff97d2bfc33c5d52eb1e12946fc04556d91b5e42)) 30 | 31 | ### [0.6.1](https://www.github.com/HalfdanJ/svelte-google-auth/compare/v0.6.0...v0.6.1) (2022-11-02) 32 | 33 | 34 | ### Bug Fixes 35 | 36 | * Fix types on getAuthLocals ([#24](https://www.github.com/HalfdanJ/svelte-google-auth/issues/24)) ([d89979b](https://www.github.com/HalfdanJ/svelte-google-auth/commit/d89979bdde2f52334b05431ecbdc246f0dce6ac7)) 37 | 38 | ## [0.6.0](https://www.github.com/HalfdanJ/svelte-google-auth/compare/v0.5.2...v0.6.0) (2022-09-19) 39 | 40 | 41 | ### Features 42 | 43 | * Redirect to current path by default ([b6b2d76](https://www.github.com/HalfdanJ/svelte-google-auth/commit/b6b2d760492660cb8121478483c5cb4490500ef1)) 44 | 45 | 46 | ### Bug Fixes 47 | 48 | * Set 30 day expiration on cookie headers ([52e5de8](https://www.github.com/HalfdanJ/svelte-google-auth/commit/52e5de8155753c74bdb5a5e0759d857908a8efd9)) 49 | 50 | ### [0.5.2](https://www.github.com/HalfdanJ/svelte-google-auth/compare/v0.5.1...v0.5.2) (2022-09-15) 51 | 52 | 53 | ### Bug Fixes 54 | 55 | * Changes to hooks and invalidate to support latest sveltekit ([#20](https://www.github.com/HalfdanJ/svelte-google-auth/issues/20)) ([4626192](https://www.github.com/HalfdanJ/svelte-google-auth/commit/46261921b21c1415c0ee359e34dd4c9940b776b8)) 56 | 57 | ### [0.5.1](https://www.github.com/HalfdanJ/svelte-google-auth/compare/v0.5.0...v0.5.1) (2022-09-08) 58 | 59 | 60 | ### Bug Fixes 61 | 62 | * Pass invalidate to initialize ([#17](https://www.github.com/HalfdanJ/svelte-google-auth/issues/17)) ([1a4b7b4](https://www.github.com/HalfdanJ/svelte-google-auth/commit/1a4b7b4d466ffcccfa2561b8d1944942820a9f45)) 63 | 64 | ## [0.5.0](https://www.github.com/HalfdanJ/svelte-google-auth/compare/v0.4.0...v0.5.0) (2022-09-08) 65 | 66 | 67 | ### Features 68 | 69 | * Maintain local context in lib instead of relying on page stores ([#15](https://www.github.com/HalfdanJ/svelte-google-auth/issues/15)) ([6259882](https://www.github.com/HalfdanJ/svelte-google-auth/commit/62598821f89c1b71dc852b86228a4515f3ef10e0)) 70 | 71 | ## [0.4.0](https://www.github.com/HalfdanJ/svelte-google-auth/compare/v0.3.4...v0.4.0) (2022-09-06) 72 | 73 | 74 | ### Features 75 | 76 | * Add the ability to pass resolve options to the auth handler ([#11](https://www.github.com/HalfdanJ/svelte-google-auth/issues/11)) ([c200980](https://www.github.com/HalfdanJ/svelte-google-auth/commit/c200980bd7facb7fe42774957eb430de6d832f35)) 77 | 78 | ### [0.3.4](https://www.github.com/HalfdanJ/svelte-google-auth/compare/v0.3.3...v0.3.4) (2022-08-30) 79 | 80 | 81 | ### Bug Fixes 82 | 83 | * Solve dynamic import issues when bundled ([58c29d3](https://www.github.com/HalfdanJ/svelte-google-auth/commit/58c29d36e1865c35f1947e271110cd9e2528aac2)) 84 | 85 | ### [0.3.3](https://www.github.com/HalfdanJ/svelte-google-auth/compare/v0.3.2...v0.3.3) (2022-08-30) 86 | 87 | 88 | ### Bug Fixes 89 | 90 | * dynamic import of app paths ([af8e7a5](https://www.github.com/HalfdanJ/svelte-google-auth/commit/af8e7a5d8ac9fed4a61abbda758966ac4f7bf562)) 91 | 92 | ### [0.3.2](https://www.github.com/HalfdanJ/svelte-google-auth/compare/v0.3.1...v0.3.2) (2022-08-30) 93 | 94 | 95 | ### Bug Fixes 96 | 97 | * Dont export client in index ([ced78ea](https://www.github.com/HalfdanJ/svelte-google-auth/commit/ced78eae9ee3e19169167b5bbd23c6dec263fde6)) 98 | 99 | ### [0.3.1](https://www.github.com/HalfdanJ/svelte-google-auth/compare/v0.3.0...v0.3.1) (2022-08-30) 100 | 101 | 102 | ### Bug Fixes 103 | 104 | * better typings ([#6](https://www.github.com/HalfdanJ/svelte-google-auth/issues/6)) ([1b4e5a4](https://www.github.com/HalfdanJ/svelte-google-auth/commit/1b4e5a47a411051f5e2d3c8bb664e872d499c8d4)) 105 | 106 | ## [0.3.0](https://www.github.com/HalfdanJ/svelte-google-auth/compare/v0.2.0...v0.3.0) (2022-08-29) 107 | 108 | 109 | ### Features 110 | 111 | * Redirect authentication ([6810722](https://www.github.com/HalfdanJ/svelte-google-auth/commit/6810722cba4e467a80fa1ccef6e8b47f3829a790)) 112 | 113 | ## [0.2.0](https://www.github.com/HalfdanJ/svelte-google-auth/compare/v0.1.0...v0.2.0) (2022-08-27) 114 | 115 | 116 | ### Features 117 | 118 | * Publish it ([15b8db6](https://www.github.com/HalfdanJ/svelte-google-auth/commit/15b8db664c1d61cd2b818438e363de48f488b9ea)) 119 | 120 | ## 0.1.0 (2022-08-27) 121 | 122 | 123 | ### Features 124 | 125 | * Publish it ([15b8db6](https://www.github.com/HalfdanJ/svelte-google-auth/commit/15b8db664c1d61cd2b818438e363de48f488b9ea)) 126 | -------------------------------------------------------------------------------- /src/lib/server.ts: -------------------------------------------------------------------------------- 1 | import { error, type Handle, type ResolveOptions } from '@sveltejs/kit'; 2 | import cookie from 'cookie'; 3 | import type { Credentials, OAuth2Client } from 'google-auth-library'; 4 | import { google } from 'googleapis'; 5 | import jwt from 'jsonwebtoken'; 6 | import { AUTH_CODE_CALLBACK_URL, AUTH_SIGNOUT_URL } from './constants.js'; 7 | 8 | export type DecodedIdToken = { 9 | iss: string; 10 | azp: string; 11 | aud: string; 12 | sub: string; 13 | hd: string; 14 | email: string; 15 | email_verified: string; 16 | at_hash: string; 17 | name: string; 18 | picture: string; 19 | given_name: string; 20 | family_name: string; 21 | locale: string; 22 | iat: string; 23 | exp: string; 24 | }; 25 | 26 | export interface AuthLocals extends App.Locals { 27 | user?: DecodedIdToken; 28 | token?: Credentials; 29 | client_id: string; 30 | client_secret: string; 31 | client: OAuth2Client; 32 | } 33 | 34 | export interface AuthLocalsSignedIn extends AuthLocals { 35 | user: DecodedIdToken; 36 | token: Credentials; 37 | client_id: string; 38 | client_secret: string; 39 | client: OAuth2Client; 40 | } 41 | 42 | export interface AuthClientData { 43 | client_id: string; 44 | user?: DecodedIdToken; 45 | access_token?: string; 46 | } 47 | 48 | /** Client data when user is signed in */ 49 | export interface AuthClientDataSignedIn { 50 | client_id: string; 51 | user: DecodedIdToken; 52 | access_token: string; 53 | } 54 | 55 | /** 56 | * Cast the locals to locals including the auth 57 | * @param locals 58 | */ 59 | export function getAuthLocals(locals: AuthLocalsSignedIn): AuthLocalsSignedIn & App.Locals; 60 | export function getAuthLocals(locals: App.Locals): AuthLocals & App.Locals; 61 | export function getAuthLocals(locals: App.Locals | AuthLocalsSignedIn) { 62 | return locals as (AuthLocals | AuthLocalsSignedIn) & App.Locals; 63 | } 64 | 65 | /** 66 | * Hydrates the client with data from auth 67 | * 68 | * @param locals the apps locals 69 | * @returns data served to the client 70 | */ 71 | export function hydrateAuth(locals: AuthLocalsSignedIn): { auth: AuthClientDataSignedIn }; 72 | export function hydrateAuth(locals: App.Locals): { auth: AuthClientData }; 73 | export function hydrateAuth(locals: App.Locals | AuthLocalsSignedIn): { 74 | auth: AuthClientData | AuthClientDataSignedIn; 75 | } { 76 | const authLocals = getAuthLocals(locals); 77 | return { 78 | auth: { 79 | user: authLocals.user, 80 | client_id: authLocals.client_id, 81 | access_token: authLocals?.token?.access_token ?? undefined 82 | } 83 | }; 84 | } 85 | 86 | export function getOAuth2Client(locals: App.Locals) { 87 | const authLocals = getAuthLocals(locals); 88 | return authLocals.client; 89 | } 90 | export function isSignedIn(locals: App.Locals): locals is AuthLocalsSignedIn { 91 | return !!getAuthLocals(locals).user; 92 | } 93 | 94 | export function generateAuthUrl( 95 | locals: App.Locals, 96 | url: URL, 97 | scopes: string[], 98 | redirectUrl?: string, 99 | prompt = 'consent' 100 | ) { 101 | const authLocals = getAuthLocals(locals); 102 | 103 | if (!redirectUrl) redirectUrl = url.pathname; 104 | 105 | const redirect_uri = `${url.origin}${AUTH_CODE_CALLBACK_URL}`; 106 | const client = new google.auth.OAuth2( 107 | authLocals.client_id, 108 | authLocals.client_secret, 109 | redirect_uri 110 | ); 111 | 112 | return client.generateAuthUrl({ 113 | access_type: 'offline', 114 | response_type: 'code', 115 | prompt, 116 | scope: scopes, 117 | redirect_uri, 118 | state: redirectUrl 119 | }); 120 | } 121 | 122 | export class SvelteGoogleAuthHook { 123 | constructor( 124 | private client: { 125 | client_id: string; 126 | client_secret: string; 127 | jwt_secret?: string; 128 | [key: string]: unknown; 129 | }, 130 | private cookie_name = 'svgoogleauth', 131 | private resolveOptions?: ResolveOptions 132 | ) {} 133 | 134 | public handleAuth: Handle = async ({ event, resolve }) => { 135 | // Read stored data from signed auth cookie 136 | const storedTokens = this.parseSignedCookie(event.request); 137 | // Create a oauth2 client 138 | const oauth2Client = new google.auth.OAuth2(this.client.client_id, this.client.client_secret); 139 | 140 | (event.locals as AuthLocals) = { 141 | ...event.locals, 142 | client_id: this.client.client_id, 143 | client_secret: this.client.client_secret, 144 | client: oauth2Client 145 | }; 146 | 147 | // Check if request contains autorization header 148 | const autorizationHeader = event.request.headers.get('Authorization'); 149 | if (autorizationHeader?.toLowerCase().startsWith('bearer')) { 150 | const bearerToken = autorizationHeader.match(/^bearer (.+)$/i)?.[1]; 151 | 152 | if (bearerToken) { 153 | const [tokenInfo, userInfo] = await Promise.all([ 154 | this.getTokenInfo(bearerToken), 155 | this.getUserInfo(bearerToken) 156 | ]).catch(() => [null, null]); 157 | 158 | if (tokenInfo && userInfo) { 159 | const user: DecodedIdToken = { ...tokenInfo, ...userInfo }; 160 | (event.locals as AuthLocals) = { 161 | ...event.locals, 162 | user, 163 | token: { access_token: bearerToken, scope: tokenInfo.scope, token_type: 'Bearer' }, 164 | client_id: this.client.client_id, 165 | client_secret: this.client.client_secret, 166 | client: oauth2Client 167 | }; 168 | return await resolve(event, this.resolveOptions); 169 | } 170 | } 171 | 172 | return new Response(`Invalid bearer token, expected oauth2 access token`, { 173 | status: 401 174 | }); 175 | } else { 176 | try { 177 | if (storedTokens?.refresh_token) { 178 | // Obtain a valid access token 179 | const accessToken = await this.getAccessToken(storedTokens); 180 | // Decode user information from id token 181 | const user = this.decodeIdToken(storedTokens); 182 | 183 | // Set credentials on oauth2 client 184 | oauth2Client.setCredentials(storedTokens); 185 | 186 | storedTokens.access_token = accessToken; 187 | 188 | // Store tokens and user in locals 189 | (event.locals as AuthLocals) = { 190 | ...event.locals, 191 | user, 192 | token: storedTokens, 193 | client_id: this.client.client_id, 194 | client_secret: this.client.client_secret, 195 | client: oauth2Client 196 | }; 197 | } 198 | } catch (e) { 199 | // Something went wrong parsing stored refresh tokens. 200 | // Dont update locals with tokens, and let application 201 | // decide what to do with lack of tokens. 202 | } 203 | 204 | // Inject url's for handling sign in and out 205 | if (event.url.pathname === AUTH_CODE_CALLBACK_URL) { 206 | if (event.request.method === 'POST') { 207 | return this.handlePostCode({ event, resolve }); 208 | } else if (event.request.method === 'GET') { 209 | return this.handleGetCode({ event, resolve }); 210 | } 211 | } else if (event.url.pathname === AUTH_SIGNOUT_URL) { 212 | return this.handleSignOut({ event, resolve }); 213 | } 214 | 215 | return await resolve(event, this.resolveOptions); 216 | } 217 | }; 218 | 219 | private handleSignOut: Handle = async () => { 220 | // Overwrite the stored cookie with an empty jwt token 221 | const signed = this.signJwtTokens({}); 222 | 223 | return new Response('signed out', { 224 | headers: this.setCookieHeader(signed) 225 | }); 226 | }; 227 | 228 | private handlePostCode: Handle = async ({ event }) => { 229 | // https://developers.google.com/identity/oauth2/web/guides/use-code-model#validate_the_request 230 | if (event.request.headers.get('X-Requested-With') !== 'XmlHttpRequest') { 231 | throw error(403, 'Request is not valid. Does not contain correct X-Requested-With header'); 232 | } 233 | 234 | const formData = await event.request.formData(); 235 | const code = formData.get('code'); 236 | 237 | if (!code) { 238 | throw error(500, 'No code to get token for'); 239 | } 240 | const tokens = await this.getTokenFromCode(code.toString(), 'postmessage'); 241 | const signedTokens = this.signJwtTokens(tokens); 242 | 243 | return new Response('ok', { 244 | headers: this.setCookieHeader(signedTokens) 245 | }); 246 | }; 247 | 248 | private handleGetCode: Handle = async ({ event }) => { 249 | const code = event.url.searchParams.get('code'); 250 | const state = event.url.searchParams.get('state') || '/'; 251 | if (!code) { 252 | throw error(500, 'No code to get token for'); 253 | } 254 | const redirect_uri = `${event.url.origin}${event.url.pathname}`; 255 | const tokens = await this.getTokenFromCode(code.toString(), redirect_uri); 256 | const signedTokens = this.signJwtTokens(tokens); 257 | 258 | return new Response(`Ok`, { 259 | status: 302, 260 | headers: { 261 | ...this.setCookieHeader(signedTokens), 262 | Location: `${event.url.origin}${state}` 263 | } 264 | }); 265 | }; 266 | 267 | private async getTokenFromCode(code: string, redirect_uri: string) { 268 | const oauth2Client = new google.auth.OAuth2( 269 | this.client.client_id, 270 | this.client.client_secret, 271 | redirect_uri 272 | ); 273 | 274 | const { tokens } = await oauth2Client.getToken(code.toString()).catch((e) => { 275 | if (e.message === 'redirect_uri_mismatch') { 276 | console.error(`Redirect uri mismatch. Client configured with uri '${redirect_uri}'`); 277 | throw error(500, 'Oauth redirect uri mismatch'); 278 | } 279 | throw error( 280 | 403, 281 | e.response?.data?.error_description ?? 'Could not obtain tokens from oauth2 code' 282 | ); 283 | }); 284 | return tokens; 285 | } 286 | 287 | private async getAccessToken(tokens: Credentials) { 288 | const client = new google.auth.OAuth2(this.client.client_id, this.client.client_secret); 289 | client.setCredentials(tokens); 290 | 291 | const newAccessTokens = await client.getAccessToken(); 292 | return newAccessTokens.token; 293 | } 294 | 295 | private signJwtTokens(tokens: Credentials) { 296 | const key = this.client.jwt_secret ?? this.client.client_secret; 297 | return jwt.sign(tokens, key); 298 | } 299 | 300 | private parseSignedCookie(request: Request): null | Credentials { 301 | const cookies = request.headers.get('cookie'); 302 | if (!cookies) return null; 303 | 304 | const parsedCookies = cookie.parse(cookies); 305 | const authCookie = parsedCookies[this.cookie_name] ?? null; 306 | if (!authCookie) return null; 307 | 308 | const key = this.client.jwt_secret ?? this.client.client_secret; 309 | try { 310 | return jwt.verify(authCookie, key) as Credentials; 311 | } catch (e) { 312 | console.warn(e); 313 | return null; 314 | } 315 | } 316 | 317 | private decodeIdToken(tokens: Credentials) { 318 | if (!tokens.id_token) return undefined; 319 | const decoded = jwt.decode(tokens.id_token) as unknown as DecodedIdToken; 320 | if (decoded.iss !== 'https://accounts.google.com') 321 | throw error(403, 'Invalid id_token issuer ' + decoded.iss); 322 | return decoded; 323 | } 324 | 325 | private setCookieHeader(signedTokens: string) { 326 | const maxAgeDays = 30; 327 | return { 328 | 'set-cookie': `${this.cookie_name}=${signedTokens}; Path=/; HttpOnly; Secure; Max-Age=${ 329 | maxAgeDays * 86400 330 | }` 331 | }; 332 | } 333 | 334 | private getTokenInfo(accessToken: string) { 335 | return fetch(`https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=${accessToken}`) 336 | .then((res) => res.json()) 337 | .then((res) => { 338 | if (res.error) throw new Error(res.error); 339 | return res; 340 | }); 341 | } 342 | private getUserInfo(accessToken: string) { 343 | return fetch(`https://www.googleapis.com/oauth2/v3/userinfo?access_token=${accessToken}`) 344 | .then((res) => res.json()) 345 | .then((res) => { 346 | if (res.error) throw new Error(res.error); 347 | return res; 348 | }); 349 | } 350 | } 351 | --------------------------------------------------------------------------------