├── .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 | signOut()}>Sign out
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 | signOut()}>Sign out
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 |
17 |
18 |
19 | signOut()}>Sign out
20 |
21 | {:else}
22 |
23 |
25 | signIn([
26 | 'openid',
27 | 'profile',
28 | 'email',
29 | 'https://www.googleapis.com/auth/calendar.readonly'
30 | ])}>Sign in
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 | [](https://www.npmjs.com/package/svelte-google-auth)
4 | [](https://github.com/mkenney/software-guides/blob/master/STABILITY-BADGES.md#beta)
5 | [](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 | signIn()}>Sign In
101 | signOut()}>Sign Out
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 |
--------------------------------------------------------------------------------