Merge branch 'main' into CrispyBacon1999/main

This commit is contained in:
Christopher McCulloh 2022-04-20 13:41:30 -04:00
commit 5c984a3653
No known key found for this signature in database
GPG Key ID: B276943E3A6BE196
34 changed files with 4373 additions and 3874 deletions

4
.eslintignore Normal file
View File

@ -0,0 +1,4 @@
README.md
app/
*.cjs
dist/

21
.eslintrc-shared.cjs Normal file
View File

@ -0,0 +1,21 @@
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
extends: ["prettier"],
plugins: ["@typescript-eslint"],
parserOptions: {
sourceType: "module",
ecmaVersion: 2019,
},
env: {
browser: true,
es2017: true,
node: true,
},
rules: {
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": ["off", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-non-null-assertion": "off",
},
};

View File

@ -1,22 +1,9 @@
module.exports = { module.exports = {
root: true, root: true,
parser: "@typescript-eslint/parser", parser: "@typescript-eslint/parser",
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"], extends: [
plugins: ["@typescript-eslint"], "eslint:recommended",
ignorePatterns: ["*.cjs", "app/**/*"], "plugin:@typescript-eslint/recommended",
parserOptions: { "./.eslintrc-shared.cjs",
sourceType: "module", ],
ecmaVersion: 2019,
},
rules: {
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": ["off", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-non-null-assertion": "off",
},
env: {
browser: true,
es2017: true,
node: true,
},
}; };

View File

@ -15,9 +15,6 @@ jobs:
- uses: actions/setup-node@v1 - uses: actions/setup-node@v1
with: with:
node-version: 14 node-version: 14
- run: yarn install - run: yarn install --frozen-lockfile && yarn lint
- run: yarn lint - run: yarn install --frozen-lockfile && yarn lint
- run: yarn install
working-directory: app
- run: yarn lint
working-directory: app working-directory: app

View File

@ -12,7 +12,7 @@ jobs:
- uses: actions/setup-node@v1 - uses: actions/setup-node@v1
with: with:
node-version: 14 node-version: 14
- run: yarn install - run: yarn install --frozen-lockfile
- run: yarn build - run: yarn build
- uses: JS-DevTools/npm-publish@v1 - uses: JS-DevTools/npm-publish@v1
with: with:

2
.gitignore vendored
View File

@ -76,4 +76,4 @@ typings/
.fusebox/ .fusebox/
# Build output # Build output
dist/* dist/

View File

@ -1,2 +1,4 @@
dist/** dist/
node_modules/** node_modules/
README.md
app/

View File

@ -31,7 +31,7 @@ SvelteKitAuth also comes with first-class support for Typescript out of the box,
SvelteKitAuth is very easy to setup! All you need to do is instantiate the `SvelteKitAuth` class, and configure it with some default providers, as well as a JWT secret key used to verify the cookies: SvelteKitAuth is very easy to setup! All you need to do is instantiate the `SvelteKitAuth` class, and configure it with some default providers, as well as a JWT secret key used to verify the cookies:
_**Warning**: env variables prefixed with `VITE_` can be exposed and leaked into client-side bundles if they are referenced in any client-side code. Make sure this is not the case, or consider using an alternative method such as loading them via dotenv directly instead._ ***Warning**: env variables prefixed with `VITE_` can be exposed and leaked into client-side bundles if they are referenced in any client-side code. Make sure this is not the case, or consider using an alternative method such as loading them via dotenv directly instead.*
```ts ```ts
export const appAuth = new SvelteKitAuth({ export const appAuth = new SvelteKitAuth({
@ -144,6 +144,10 @@ SvelteKitAuth is inspired by the [NextAuth.js](https://next-auth.js.org/) packag
As it leverages classes and Typescript, the implementation of such providers is very straightforward, and in the future it will even be possible to register multiple SvelteKitAuth handlers in the same project, should the need arise, by leveraging a class-based client and server setup. As it leverages classes and Typescript, the implementation of such providers is very straightforward, and in the future it will even be possible to register multiple SvelteKitAuth handlers in the same project, should the need arise, by leveraging a class-based client and server setup.
## Examples
Looking for help? Check out the [example app](./app/) in the repository source. Make something cool you want to show off? Share it with others [in the discussion section](https://github.com/Dan6erbond/sk-auth/discussions/72).
## Contributing ## Contributing
🚧 Work in Progress! 🚧 Work in Progress!

6
app/.eslintignore Normal file
View File

@ -0,0 +1,6 @@
node_modules/
README.md
*.cjs
.svelte-kit/
static/
build/

View File

@ -1,20 +1,15 @@
module.exports = { module.exports = {
root: true, root: true,
parser: "@typescript-eslint/parser", parser: "@typescript-eslint/parser",
extends: ["../.eslintrc.cjs"], extends: ["../.eslintrc-shared.cjs"],
plugins: ["svelte3", "@typescript-eslint"], plugins: ["svelte3"],
ignorePatterns: ["*.cjs"], overrides: [
overrides: [{ files: ["*.svelte"], processor: "svelte3/svelte3" }], {
files: ["*.svelte"],
processor: "svelte3/svelte3",
},
],
settings: { settings: {
"svelte3/typescript": () => require("typescript"), "svelte3/typescript": () => require("typescript"),
}, },
parserOptions: {
sourceType: "module",
ecmaVersion: 2019,
},
env: {
browser: true,
es2017: true,
node: true,
},
}; };

View File

@ -1,4 +1,4 @@
.svelte-kit/** .svelte-kit/
static/** static/
build/** build/
node_modules/** node_modules/

View File

@ -5,11 +5,11 @@
"dev": "svelte-kit dev", "dev": "svelte-kit dev",
"build": "svelte-kit build", "build": "svelte-kit build",
"preview": "svelte-kit preview", "preview": "svelte-kit preview",
"lint": "prettier --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .", "lint": "prettier --check --plugin-search-dir=. . && eslint .",
"format": "prettier --write --plugin-search-dir=. ." "format": "prettier --write --plugin-search-dir=. ."
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/kit": "next", "@sveltejs/kit": "^1.0.0-next.259",
"@types/prismjs": "^1.16.5", "@types/prismjs": "^1.16.5",
"@typescript-eslint/eslint-plugin": "^4.19.0", "@typescript-eslint/eslint-plugin": "^4.19.0",
"@typescript-eslint/parser": "^4.19.0", "@typescript-eslint/parser": "^4.19.0",
@ -33,6 +33,7 @@
"dependencies": { "dependencies": {
"@fontsource/fira-mono": "^4.3.0", "@fontsource/fira-mono": "^4.3.0",
"@fontsource/inter": "^4.3.0", "@fontsource/inter": "^4.3.0",
"clipboard": "^2.0.8",
"clsx": "^1.1.1", "clsx": "^1.1.1",
"prismjs": "^1.23.0", "prismjs": "^1.23.0",
"sk-auth": "file:../" "sk-auth": "file:../"

2
app/src/global.d.ts vendored
View File

@ -11,5 +11,7 @@ interface ImportMetaEnv {
VITE_TWITTER_API_SECRET: string; VITE_TWITTER_API_SECRET: string;
VITE_REDDIT_API_KEY: string; VITE_REDDIT_API_KEY: string;
VITE_REDDIT_API_SECRET: string; VITE_REDDIT_API_SECRET: string;
VITE_SPOTIFY_CLIENT_ID: string;
VITE_SPOTIFY_CLIENT_SECRET: string;
VITE_JWT_SECRET_KEY: string; VITE_JWT_SECRET_KEY: string;
} }

View File

@ -1,13 +1,15 @@
import type { Handle } from "@sveltejs/kit"; import type { Handle } from "@sveltejs/kit";
import { appAuth } from "$lib/appAuth"; import { appAuth } from "$lib/appAuth";
export const handle: Handle = async ({ request, render }) => { export const handle: Handle = async ({ event, resolve }) => {
// TODO https://github.com/sveltejs/kit/issues/1046 // TODO https://github.com/sveltejs/kit/issues/1046
if (request.query.has("_method")) {
request.method = request.query.get("_method").toUpperCase();
if (event.request.query.has("_method")) {
event.request.method = event.request.query.get("_method").toUpperCase();
} }
const response = await render(request); const response = await resolve(event);
return response; return response;
}; };

View File

@ -5,6 +5,7 @@ import {
GoogleOAuth2Provider, GoogleOAuth2Provider,
RedditOAuth2Provider, RedditOAuth2Provider,
TwitterAuthProvider, TwitterAuthProvider,
SpotifyOAuth2Provider,
} from "sk-auth/providers"; } from "sk-auth/providers";
export const appAuth = new SvelteKitAuth({ export const appAuth = new SvelteKitAuth({
@ -45,6 +46,13 @@ export const appAuth = new SvelteKitAuth({
return { ...slim, provider: "reddit" }; return { ...slim, provider: "reddit" };
}, },
}), }),
new SpotifyOAuth2Provider({
clientId: import.meta.env.VITE_SPOTIFY_CLIENT_ID,
clientSecret: import.meta.env.VITE_SPOTIFY_CLIENT_SECRET,
profile(profile) {
return { ...profile, provider: "spotify" };
},
}),
], ],
callbacks: { callbacks: {
jwt(token, profile) { jwt(token, profile) {

View File

@ -180,6 +180,38 @@
<span>Sign in with Reddit</span> <span>Sign in with Reddit</span>
</a> </a>
<a
href="/api/auth/signin/spotify?redirect=/profile"
class={clsx(
"text-sm",
"md:text-base",
"inline-flex",
"space-x-4",
"py-2",
"px-4",
"border-gray-400",
"rounded",
"hover:no-underline",
"border",
"hover:bg-gray-100",
"transition-colors",
"items-center",
)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
version="1.1"
viewBox="0 0 168 168"
class={clsx("h-4", "w-4", "md:h-6", "md:w-6")}
>
<path
fill="#1ED760"
d="m83.996 0.277c-46.249 0-83.743 37.493-83.743 83.742 0 46.251 37.494 83.741 83.743 83.741 46.254 0 83.744-37.49 83.744-83.741 0-46.246-37.49-83.738-83.745-83.738l0.001-0.004zm38.404 120.78c-1.5 2.46-4.72 3.24-7.18 1.73-19.662-12.01-44.414-14.73-73.564-8.07-2.809 0.64-5.609-1.12-6.249-3.93-0.643-2.81 1.11-5.61 3.926-6.25 31.9-7.291 59.263-4.15 81.337 9.34 2.46 1.51 3.24 4.72 1.73 7.18zm10.25-22.805c-1.89 3.075-5.91 4.045-8.98 2.155-22.51-13.839-56.823-17.846-83.448-9.764-3.453 1.043-7.1-0.903-8.148-4.35-1.04-3.453 0.907-7.093 4.354-8.143 30.413-9.228 68.222-4.758 94.072 11.127 3.07 1.89 4.04 5.91 2.15 8.976v-0.001zm0.88-23.744c-26.99-16.031-71.52-17.505-97.289-9.684-4.138 1.255-8.514-1.081-9.768-5.219-1.254-4.14 1.08-8.513 5.221-9.771 29.581-8.98 78.756-7.245 109.83 11.202 3.73 2.209 4.95 7.016 2.74 10.733-2.2 3.722-7.02 4.949-10.73 2.739z"
/>
</svg>
<span>Sign in with Spotify</span>
</a>
<p class={clsx("text-gray-600", "text-center", "border-gray-400", "border-b", "pb-2")}> <p class={clsx("text-gray-600", "text-center", "border-gray-400", "border-b", "pb-2")}>
Coming soon. Coming soon.
</p> </p>

View File

@ -201,6 +201,47 @@
{/if} {/if}
</div> </div>
</div> </div>
<div class={clsx("flex", "items-center", "space-x-4")}>
<svg
xmlns="http://www.w3.org/2000/svg"
version="1.1"
viewBox="0 0 168 168"
class={clsx("h-4", "w-4", "md:h-6", "md:w-6")}
>
<path
fill="#1ED760"
d="m83.996 0.277c-46.249 0-83.743 37.493-83.743 83.742 0 46.251 37.494 83.741 83.743 83.741 46.254 0 83.744-37.49 83.744-83.741 0-46.246-37.49-83.738-83.745-83.738l0.001-0.004zm38.404 120.78c-1.5 2.46-4.72 3.24-7.18 1.73-19.662-12.01-44.414-14.73-73.564-8.07-2.809 0.64-5.609-1.12-6.249-3.93-0.643-2.81 1.11-5.61 3.926-6.25 31.9-7.291 59.263-4.15 81.337 9.34 2.46 1.51 3.24 4.72 1.73 7.18zm10.25-22.805c-1.89 3.075-5.91 4.045-8.98 2.155-22.51-13.839-56.823-17.846-83.448-9.764-3.453 1.043-7.1-0.903-8.148-4.35-1.04-3.453 0.907-7.093 4.354-8.143 30.413-9.228 68.222-4.758 94.072 11.127 3.07 1.89 4.04 5.91 2.15 8.976v-0.001zm0.88-23.744c-26.99-16.031-71.52-17.505-97.289-9.684-4.138 1.255-8.514-1.081-9.768-5.219-1.254-4.14 1.08-8.513 5.221-9.771 29.581-8.98 78.756-7.245 109.83 11.202 3.73 2.209 4.95 7.016 2.74 10.733-2.2 3.722-7.02 4.949-10.73 2.739z"
/>
</svg>
<div class={clsx("flex", "flex-col", "items-start", "space-y-1")}>
{#if $session.user.connections.spotify}
<p class={clsx("font-bold")}>Signed in as:</p>
{$session.user.connections.spotify.display_name}
{:else}
<p class={clsx("font-bold")}>Not signed in</p>
<div
class={clsx(
"text-xs",
"md:text-sm",
"py-1",
"px-2",
"border-gray-400",
"rounded",
"hover:no-underline",
"border",
"hover:bg-gray-100",
"transition-colors",
"items-center",
"cursor-not-allowed",
"inline-block",
)}
>
Connect
</div>
{/if}
</div>
</div>
</div> </div>
<p class={clsx("text-lg", "mb-2")}>Session</p> <p class={clsx("text-lg", "mb-2")}>Session</p>

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "sk-auth", "name": "sk-auth",
"version": "0.3.7", "version": "0.4.1",
"description": "Authentication library for use with SvelteKit featuring built-in OAuth providers and zero restriction customization!", "description": "Authentication library for use with SvelteKit featuring built-in OAuth providers and zero restriction customization!",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.esm.js", "module": "dist/index.esm.js",
@ -18,10 +18,11 @@
"README.md" "README.md"
], ],
"scripts": { "scripts": {
"prepare": "npm run build",
"build": "rollup --config", "build": "rollup --config",
"dev": "rollup --config --watch", "dev": "rollup --config --watch",
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"lint": "prettier --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .", "lint": "prettier --check --plugin-search-dir=. . && eslint .",
"format": "prettier --write --plugin-search-dir=. ." "format": "prettier --write --plugin-search-dir=. ."
}, },
"keywords": [ "keywords": [
@ -47,7 +48,7 @@
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-typescript": "^8.2.1", "@rollup/plugin-typescript": "^8.2.1",
"@sveltejs/kit": "^1.0.0-next.107", "@sveltejs/kit": "^1.0.0-next.259",
"@types/jsonwebtoken": "^8.5.1", "@types/jsonwebtoken": "^8.5.1",
"@typescript-eslint/eslint-plugin": "^4.23.0", "@typescript-eslint/eslint-plugin": "^4.23.0",
"@typescript-eslint/parser": "^4.23.0", "@typescript-eslint/parser": "^4.23.0",

View File

@ -1,6 +1,6 @@
import type { GetSession, RequestHandler } from "@sveltejs/kit"; import type { GetSession, RequestHandler } from "@sveltejs/kit";
import type { EndpointOutput, ServerRequest } from "@sveltejs/kit/types/endpoint"; import type { EndpointOutput } from "@sveltejs/kit/types/endpoint";
import type { Headers } from "@sveltejs/kit/types/helper"; import { RequestEvent } from "@sveltejs/kit/types/hooks";
import cookie from "cookie"; import cookie from "cookie";
import * as jsonwebtoken from "jsonwebtoken"; import * as jsonwebtoken from "jsonwebtoken";
import type { JWT, Session } from "./interfaces"; import type { JWT, Session } from "./interfaces";
@ -13,6 +13,7 @@ interface AuthConfig {
jwtSecret?: string; jwtSecret?: string;
jwtExpiresIn?: string | number; jwtExpiresIn?: string | number;
host?: string; host?: string;
protocol?: string;
basePath?: string; basePath?: string;
} }
@ -43,12 +44,12 @@ export class Auth {
return "svelte_auth_secret"; return "svelte_auth_secret";
} }
async getToken(headers: Headers) { async getToken(headers: any) {
if (!headers.cookie) { if (!headers.get("cookie")) {
return null; return null;
} }
const cookies = cookie.parse(headers.cookie); const cookies = cookie.parse(headers.get("cookie"));
if (!cookies.svelteauthjwt) { if (!cookies.svelteauthjwt) {
return null; return null;
@ -69,7 +70,9 @@ export class Auth {
} }
getBaseUrl(host?: string) { getBaseUrl(host?: string) {
return this.config?.host ?? `http://${host}`; const protocol = this.config?.protocol ?? "https";
host = this.config?.host ?? host;
return `${protocol}://${host}`;
} }
getPath(path: string) { getPath(path: string) {
@ -82,7 +85,7 @@ export class Auth {
return new URL(pathname, this.getBaseUrl(host)).href; return new URL(pathname, this.getBaseUrl(host)).href;
} }
setToken(headers: Headers, newToken: JWT | any) { setToken(headers: any, newToken: JWT | any) {
const originalToken = this.getToken(headers); const originalToken = this.getToken(headers);
return { return {
@ -109,12 +112,10 @@ export class Auth {
return redirect; return redirect;
} }
async handleProviderCallback( async handleProviderCallback(event: RequestEvent, provider: Provider): Promise<EndpointOutput> {
request: ServerRequest, const { headers } = event.request;
provider: Provider, const { url } = event;
): Promise<EndpointOutput> { const [profile, redirectUrl] = await provider.callback(event, this);
const { headers, host } = request;
const [profile, redirectUrl] = await provider.callback(request, this);
let token = (await this.getToken(headers)) ?? { user: {} }; let token = (await this.getToken(headers)) ?? { user: {} };
if (this.config?.callbacks?.jwt) { if (this.config?.callbacks?.jwt) {
@ -124,7 +125,7 @@ export class Auth {
} }
const jwt = this.signToken(token); const jwt = this.signToken(token);
const redirect = await this.getRedirectUrl(host, redirectUrl ?? undefined); const redirect = await this.getRedirectUrl(url.host, redirectUrl ?? undefined);
return { return {
status: 302, status: 302,
@ -135,11 +136,12 @@ export class Auth {
}; };
} }
async handleEndpoint(request: ServerRequest): Promise<EndpointOutput> { async handleEndpoint(event: RequestEvent): Promise<EndpointOutput> {
const { path, headers, method, host } = request; const { headers, method } = event.request;
const { url } = event;
if (path === this.getPath("signout")) { if (url.pathname === this.getPath("signout")) {
const token = this.setToken(headers, {}); const token = this.setToken(event.request.headers, {});
const jwt = this.signToken(token); const jwt = this.signToken(token);
if (method === "POST") { if (method === "POST") {
@ -153,7 +155,7 @@ export class Auth {
}; };
} }
const redirect = await this.getRedirectUrl(host); const redirect = await this.getRedirectUrl(url.host);
return { return {
status: 302, status: 302,
@ -165,7 +167,7 @@ export class Auth {
} }
const regex = new RegExp(join([this.basePath, `(?<method>signin|callback)/(?<provider>\\w+)`])); const regex = new RegExp(join([this.basePath, `(?<method>signin|callback)/(?<provider>\\w+)`]));
const match = path.match(regex); const match = url.pathname.match(regex);
if (match && match.groups) { if (match && match.groups) {
const provider = this.config?.providers?.find( const provider = this.config?.providers?.find(
@ -173,9 +175,9 @@ export class Auth {
); );
if (provider) { if (provider) {
if (match.groups.method === "signin") { if (match.groups.method === "signin") {
return await provider.signin(request, this); return await provider.signin(event, this);
} else { } else {
return await this.handleProviderCallback(request, provider); return await this.handleProviderCallback(event, provider);
} }
} }
} }
@ -186,13 +188,13 @@ export class Auth {
}; };
} }
get: RequestHandler = async (request) => { get: RequestHandler = async (event: RequestEvent): Promise<any> => {
const { path } = request; const { url } = event;
if (path === this.getPath("csrf")) { if (url.pathname === this.getPath("csrf")) {
return { body: "1234" }; // TODO: Generate real token return { body: "1234" }; // TODO: Generate real token
} else if (path === this.getPath("session")) { } else if (url.pathname === this.getPath("session")) {
const session = await this.getSession(request); const session = await this.getSession(event);
return { return {
body: { body: {
session, session,
@ -200,15 +202,16 @@ export class Auth {
}; };
} }
return await this.handleEndpoint(request); return await this.handleEndpoint(event);
}; };
post: RequestHandler = async (request) => { post: RequestHandler = async (event: RequestEvent) => {
return await this.handleEndpoint(request); return await this.handleEndpoint(event);
}; };
getSession: GetSession = async ({ headers }) => { getSession: GetSession = async (event: RequestEvent) => {
const token = await this.getToken(headers); const { request } = event;
const token = await this.getToken(request.headers);
if (token) { if (token) {
if (this.config?.callbacks?.session) { if (this.config?.callbacks?.session) {

19
src/client/helpers.ts Normal file
View File

@ -0,0 +1,19 @@
function mergePath(basePaths: (string | null)[], path: string) {
if (path.startsWith("/")) {
path = path.slice(1);
}
let retPath;
for (let basePath of basePaths) {
if (basePath !== null) {
if (!basePath.startsWith("/")) {
basePath = "/" + basePath;
}
if (!basePath.endsWith("/")) {
basePath = basePath + "/";
}
retPath = basePath + path;
}
}
return retPath;
}

View File

@ -1,14 +1,15 @@
/* import { goto } from "@sveltejs/kit/assets/runtime/app/navigation"; /* import { goto } from "@sveltejs/kit/assets/runtime/app/navigation";
import { page } from "@sveltejs/kit/assets/runtime/app/stores"; */ import { page } from "@sveltejs/kit/assets/runtime/app/stores"; */
import type { Page } from "@sveltejs/kit"; import type { LoadInput } from "@sveltejs/kit";
import type { ClientRequestConfig } from "./types";
interface SignInConfig { interface SignInConfig extends ClientRequestConfig {
redirectUrl?: string; redirectUrl?: string;
} }
export async function signIn(provider: string, data?: any, config?: SignInConfig) { export async function signIn(provider: string, data?: any, config?: SignInConfig) {
if (data) { if (data) {
const path = `/api/auth/callback/${provider}`; const path = mergePath(["/api/auth", config?.basePath ?? null], `/callback/${provider}`);
const res = await fetch(path, { const res = await fetch(path, {
method: "POST", method: "POST",
headers: { headers: {
@ -23,10 +24,10 @@ export async function signIn(provider: string, data?: any, config?: SignInConfig
if (config?.redirectUrl) { if (config?.redirectUrl) {
redirectUrl = config.redirectUrl; redirectUrl = config.redirectUrl;
} else { } else {
let $val: Page | undefined; let $val: LoadInput | undefined;
/* page.subscribe(($) => ($val = $))(); */ /* page.subscribe(($) => ($val = $))(); */
if ($val) { if ($val) {
redirectUrl = `${$val.host}${$val.path}?${$val.query}`; redirectUrl = `${$val.url.host}${$val.url.pathname}?${$val.url.searchParams}`;
} }
} }
@ -34,7 +35,7 @@ export async function signIn(provider: string, data?: any, config?: SignInConfig
redirect: redirectUrl ?? "/", redirect: redirectUrl ?? "/",
}; };
const query = new URLSearchParams(queryData); const query = new URLSearchParams(queryData);
const path = `/api/auth/login/${provider}?${query}`; const path = mergePath(["/api/auth", config?.basePath ?? null], `/signin/${provider}?${query}`);
return path; // await goto(path); return path; // await goto(path);
} }

View File

@ -1,14 +1,18 @@
/* import { session as session$ } from "$app/stores"; */ /* import { session as session$ } from "$app/stores"; */
export async function signOut() { import type { ClientRequestConfig } from "./types";
let res = await fetch("/api/auth/signout", { method: "POST" });
export async function signOut(config?: ClientRequestConfig) {
let res = await fetch(mergePath(["/api/auth", config?.basePath ?? null], "signout"), {
method: "POST",
});
const { signout } = await res.json(); const { signout } = await res.json();
if (!signout) { if (!signout) {
throw new Error("Sign out not successful!"); throw new Error("Sign out not successful!");
} }
res = await fetch("/api/auth/session"); res = await fetch(mergePath(["/api/auth", config?.basePath ?? null], "session"));
const session = await res.json(); const session = await res.json();
return session; return session;

3
src/client/types.ts Normal file
View File

@ -0,0 +1,3 @@
export interface ClientRequestConfig {
basePath?: string;
}

View File

@ -1,5 +1,5 @@
import type { EndpointOutput } from "@sveltejs/kit"; import type { EndpointOutput } from "@sveltejs/kit";
import type { ServerRequest } from "@sveltejs/kit/types/endpoint"; import { RequestEvent } from "@sveltejs/kit/types/hooks";
import type { Auth } from "../auth"; import type { Auth } from "../auth";
import type { CallbackResult } from "../types"; import type { CallbackResult } from "../types";
@ -28,12 +28,12 @@ export abstract class Provider<T extends ProviderConfig = ProviderConfig> {
} }
abstract signin<Locals extends Record<string, any> = Record<string, any>, Body = unknown>( abstract signin<Locals extends Record<string, any> = Record<string, any>, Body = unknown>(
request: ServerRequest<Locals, Body>, event: RequestEvent,
svelteKitAuth: Auth, svelteKitAuth: Auth,
): EndpointOutput | Promise<EndpointOutput>; ): EndpointOutput | Promise<EndpointOutput>;
abstract callback<Locals extends Record<string, any> = Record<string, any>, Body = unknown>( abstract callback<Locals extends Record<string, any> = Record<string, any>, Body = unknown>(
request: ServerRequest<Locals, Body>, event: RequestEvent,
svelteKitAuth: Auth, svelteKitAuth: Auth,
): CallbackResult | Promise<CallbackResult>; ): CallbackResult | Promise<CallbackResult>;
} }

50
src/providers/github.ts Normal file
View File

@ -0,0 +1,50 @@
import { OAuth2Provider, OAuth2ProviderConfig } from "./oauth2";
export interface GitHubProfile {
id: number;
login: string;
avatar_url: string;
url: string;
name: string;
// complete with more info
}
export interface GitHubTokens {
access_token: string;
token_type: string;
expires_in: number;
}
type GitHubOAuth2ProviderConfig = OAuth2ProviderConfig<GitHubProfile, GitHubTokens>;
const defaultConfig: Partial<GitHubOAuth2ProviderConfig> = {
id: "github",
scope: "user",
accessTokenUrl: "https://github.com/login/oauth/access_token",
authorizationUrl: "https://github.com/login/oauth/authorize",
profileUrl: "https://api.github.com/user",
headers: {
Accept: "application/json",
},
};
export class GitHubOAuth2Provider extends OAuth2Provider<
GitHubProfile,
GitHubTokens,
GitHubOAuth2ProviderConfig
> {
constructor(config: GitHubOAuth2ProviderConfig) {
super({
...defaultConfig,
...config,
});
}
async getUserProfile(tokens: GitHubTokens): Promise<GitHubProfile> {
const tokenType = "token"; // 🤷‍♂️ token type returned is "bearer" but GitHub uses "token" keyword
const res = await fetch(this.config.profileUrl!, {
headers: { Authorization: `${tokenType} ${tokens.access_token}` },
});
return await res.json();
}
}

View File

@ -1,9 +1,8 @@
export { Provider } from "./base"; export { Provider } from "./base";
export { TwitchOAuth2Provider } from "./twitch"; export { GitHubOAuth2Provider } from "./github";
export type { TwitchProfile, TwitchTokens } from "./twitch"; export type { GitHubProfile, GitHubTokens } from "./github";
export { GoogleOAuth2Provider } from "./google"; export { GoogleOAuth2Provider } from "./google";
export type { GoogleProfile, GoogleTokens } from "./google"; export type { GoogleProfile, GoogleTokens } from "./google";
export { TwitterAuthProvider } from "./twitter";
export { FacebookOAuth2Provider } from "./facebook"; export { FacebookOAuth2Provider } from "./facebook";
export type { FacebookProfile, FacebookTokens } from "./facebook"; export type { FacebookProfile, FacebookTokens } from "./facebook";
export { OAuth2BaseProvider } from "./oauth2.base"; export { OAuth2BaseProvider } from "./oauth2.base";
@ -13,3 +12,8 @@ export { OktaOAuth2Provider } from "./okta";
export type { OktaProfile, OktaTokens, OktaAddress } from "./okta"; export type { OktaProfile, OktaTokens, OktaAddress } from "./okta";
export { RedditOAuth2Provider } from "./reddit"; export { RedditOAuth2Provider } from "./reddit";
export type { RedditProfile, RedditTokens } from "./reddit"; export type { RedditProfile, RedditTokens } from "./reddit";
export { SpotifyOAuth2Provider } from "./spotify";
export type { SpotifyProfile, SpotifyTokens } from "./spotify";
export { TwitchOAuth2Provider } from "./twitch";
export type { TwitchProfile, TwitchTokens } from "./twitch";
export { TwitterAuthProvider } from "./twitter";

View File

@ -1,4 +1,5 @@
import type { EndpointOutput, ServerRequest } from "@sveltejs/kit/types/endpoint"; import type { EndpointOutput } from "@sveltejs/kit/types/endpoint";
import { RequestEvent } from "@sveltejs/kit/types/hooks";
import type { Auth } from "../auth"; import type { Auth } from "../auth";
import type { CallbackResult } from "../types"; import type { CallbackResult } from "../types";
import { Provider, ProviderConfig } from "./base"; import { Provider, ProviderConfig } from "./base";
@ -24,7 +25,7 @@ export abstract class OAuth2BaseProvider<
T extends OAuth2BaseProviderConfig, T extends OAuth2BaseProviderConfig,
> extends Provider<T> { > extends Provider<T> {
abstract getAuthorizationUrl( abstract getAuthorizationUrl(
request: ServerRequest, event: RequestEvent,
auth: Auth, auth: Auth,
state: string, state: string,
nonce: string, nonce: string,
@ -32,17 +33,20 @@ export abstract class OAuth2BaseProvider<
abstract getTokens(code: string, redirectUri: string): TokensType | Promise<TokensType>; abstract getTokens(code: string, redirectUri: string): TokensType | Promise<TokensType>;
abstract getUserProfile(tokens: any): ProfileType | Promise<ProfileType>; abstract getUserProfile(tokens: any): ProfileType | Promise<ProfileType>;
async signin(request: ServerRequest, auth: Auth): Promise<EndpointOutput> { async signin(event: RequestEvent, auth: Auth): Promise<EndpointOutput> {
const { method, host, query } = request; const { method } = event.request;
const state = [`redirect=${query.get("redirect") ?? this.getUri(auth, "/", host)}`].join(","); const { url } = event;
const state = [
`redirect=${url.searchParams.get("redirect") ?? this.getUri(auth, "/", url.host)}`,
].join(",");
const base64State = Buffer.from(state).toString("base64"); const base64State = Buffer.from(state).toString("base64");
const nonce = Math.round(Math.random() * 1000).toString(); // TODO: Generate random based on user values const nonce = Math.round(Math.random() * 1000).toString(); // TODO: Generate random based on user values
const url = await this.getAuthorizationUrl(request, auth, base64State, nonce); const authUrl = await this.getAuthorizationUrl(event, auth, base64State, nonce);
if (method === "POST") { if (method === "POST") {
return { return {
body: { body: {
redirect: url, redirect: authUrl,
}, },
}; };
} }
@ -50,7 +54,7 @@ export abstract class OAuth2BaseProvider<
return { return {
status: 302, status: 302,
headers: { headers: {
Location: url, Location: authUrl,
}, },
}; };
} }
@ -65,17 +69,18 @@ export abstract class OAuth2BaseProvider<
} }
} }
async callback({ query, host }: ServerRequest, auth: Auth): Promise<CallbackResult> { async callback(event: RequestEvent, auth: Auth): Promise<any> {
const code = query.get("code"); const { request, url } = event;
const redirect = this.getStateValue(query, "redirect"); const code = url.searchParams.get("code");
const redirect = this.getStateValue(url.searchParams, "redirect");
const tokens = await this.getTokens(code!, this.getCallbackUri(auth, host)); const tokens = await this.getTokens(code!, this.getCallbackUri(auth, url.host));
let user = await this.getUserProfile(tokens); let user = await this.getUserProfile(tokens);
if (this.config.profile) { if (this.config.profile) {
user = await this.config.profile(user, tokens); user = await this.config.profile(user, tokens);
} }
return [user, redirect ?? this.getUri(auth, "/", host)]; return [user, redirect ?? this.getUri(auth, "/", url.host)];
} }
} }

View File

@ -1,4 +1,4 @@
import type { ServerRequest } from "@sveltejs/kit/types/endpoint"; import { RequestEvent } from "@sveltejs/kit/types/hooks";
import type { Auth } from "../auth"; import type { Auth } from "../auth";
import { ucFirst } from "../helpers"; import { ucFirst } from "../helpers";
import { OAuth2BaseProvider, OAuth2BaseProviderConfig, OAuth2Tokens } from "./oauth2.base"; import { OAuth2BaseProvider, OAuth2BaseProviderConfig, OAuth2Tokens } from "./oauth2.base";
@ -37,19 +37,19 @@ export class OAuth2Provider<
}); });
} }
getAuthorizationUrl({ host }: ServerRequest, auth: Auth, state: string, nonce: string) { getAuthorizationUrl({ url }: RequestEvent, auth: Auth, state: string, nonce: string) {
const data = { const data = {
state, state,
nonce, nonce,
response_type: this.config.responseType, response_type: this.config.responseType,
client_id: this.config.clientId, client_id: this.config.clientId,
scope: Array.isArray(this.config.scope) ? this.config.scope.join(" ") : this.config.scope!, scope: Array.isArray(this.config.scope) ? this.config.scope.join(" ") : this.config.scope!,
redirect_uri: this.getCallbackUri(auth, host), redirect_uri: this.getCallbackUri(auth, url.host),
...(this.config.authorizationParams ?? {}), ...(this.config.authorizationParams ?? {}),
}; };
const url = `${this.config.authorizationUrl}?${new URLSearchParams(data)}`; const authUrl = `${this.config.authorizationUrl}?${new URLSearchParams(data)}`;
return url; return authUrl;
} }
async getTokens(code: string, redirectUri: string): Promise<TokensType> { async getTokens(code: string, redirectUri: string): Promise<TokensType> {

View File

@ -1,5 +1,4 @@
import { OAuth2Provider, OAuth2ProviderConfig } from "./oauth2"; import { OAuth2Provider, OAuth2ProviderConfig } from "./oauth2";
import type { ProfileCallback } from "./oauth2.base";
export interface RedditProfile { export interface RedditProfile {
is_employee: boolean; is_employee: boolean;

73
src/providers/spotify.ts Normal file
View File

@ -0,0 +1,73 @@
import { OAuth2Provider, OAuth2ProviderConfig } from "./oauth2";
export interface SpotifyProfile {
display_name: string;
email: string;
external_urls: SpotifyProfileExternalUrls;
followers: SpotifyProfileFollowers;
href: string;
id: string;
images: SpotifyProfileImage[];
type: string;
uri: string;
// This field is only available when the current user has granted access to the user-read-private scope.
explicit_content?: SpotifyExplicitContent;
// This field is only available when the current user has granted access to the user-read-private scope.
product?: string;
// This field is only available when the current user has granted access to the user-read-private scope.
country?: string;
}
export interface SpotifyExplicitContent {
filter_enabled: boolean;
filter_locked: boolean;
}
export interface SpotifyProfileImage {
height: number;
url: string;
width: string;
}
export interface SpotifyProfileFollowers {
href: string;
total: number;
}
export interface SpotifyProfileExternalUrls {
spotify: string;
}
export interface SpotifyTokens {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
scope: string;
}
interface SpotifyOAuth2ProviderConfig extends OAuth2ProviderConfig<SpotifyProfile, SpotifyTokens> {
show_dialog: boolean;
}
const defaultConfig: Partial<SpotifyOAuth2ProviderConfig> = {
id: "spotify",
scope: "user-read-email",
accessTokenUrl: "https://accounts.spotify.com/api/token",
authorizationUrl: "https://accounts.spotify.com/authorize",
profileUrl: "https://api.spotify.com/v1/me",
contentType: "application/x-www-form-urlencoded",
};
export class SpotifyOAuth2Provider extends OAuth2Provider<
SpotifyProfile,
SpotifyTokens,
SpotifyOAuth2ProviderConfig
> {
constructor(config: SpotifyOAuth2ProviderConfig) {
super({
...defaultConfig,
...config,
});
}
}

View File

@ -1,6 +1,5 @@
import type { ServerRequest } from "@sveltejs/kit/types/endpoint"; import { RequestEvent } from "@sveltejs/kit/types/hooks";
import type { Auth } from "../auth"; import type { Auth } from "../auth";
import type { CallbackResult } from "../types";
import { OAuth2BaseProvider, OAuth2BaseProviderConfig } from "./oauth2.base"; import { OAuth2BaseProvider, OAuth2BaseProviderConfig } from "./oauth2.base";
interface TwitterAuthProviderConfig extends OAuth2BaseProviderConfig { interface TwitterAuthProviderConfig extends OAuth2BaseProviderConfig {
@ -38,17 +37,17 @@ export class TwitterAuthProvider extends OAuth2BaseProvider<any, any, TwitterAut
}; };
} }
async getAuthorizationUrl({ host }: ServerRequest, auth: Auth, state: string, nonce: string) { async getAuthorizationUrl({ url }: RequestEvent, auth: Auth, state: string, nonce: string) {
const endpoint = "https://api.twitter.com/oauth/authorize"; const endpoint = "https://api.twitter.com/oauth/authorize";
const { oauthToken } = await this.getRequestToken(auth, host); const { oauthToken } = await this.getRequestToken(auth, url.host);
const data = { const data = {
oauth_token: oauthToken, oauth_token: oauthToken,
}; };
const url = `${endpoint}?${new URLSearchParams(data)}`; const authUrl = `${endpoint}?${new URLSearchParams(data)}`;
return url; return authUrl;
} }
async getTokens(oauthToken: string, oauthVerifier: string) { async getTokens(oauthToken: string, oauthVerifier: string) {
@ -71,10 +70,11 @@ export class TwitterAuthProvider extends OAuth2BaseProvider<any, any, TwitterAut
return await res.json(); return await res.json();
} }
async callback({ query, host }: ServerRequest, auth: Auth): Promise<CallbackResult> { async callback(event: RequestEvent, auth: Auth): Promise<any> {
const oauthToken = query.get("oauth_token"); const { url } = event;
const oauthVerifier = query.get("oauth_verifier"); const oauthToken = url.searchParams.get("oauth_token");
const redirect = this.getStateValue(query, "redirect"); const oauthVerifier = url.searchParams.get("oauth_verifier");
const redirect = this.getStateValue(url.searchParams, "redirect");
const tokens = await this.getTokens(oauthToken!, oauthVerifier!); const tokens = await this.getTokens(oauthToken!, oauthVerifier!);
let user = await this.getUserProfile(tokens); let user = await this.getUserProfile(tokens);
@ -83,6 +83,6 @@ export class TwitterAuthProvider extends OAuth2BaseProvider<any, any, TwitterAut
user = await this.config.profile(user, tokens); user = await this.config.profile(user, tokens);
} }
return [user, redirect ?? this.getUri(auth, "/", host)]; return [user, redirect ?? this.getUri(auth, "/", url.host)];
} }
} }

View File

@ -1,2 +1,2 @@
export type Profile = any; export type Profile = any;
export type CallbackResult = [Profile, string | null]; export type CallbackResult = [Profile, string | null, { error: string } | null];

4411
yarn.lock

File diff suppressed because it is too large Load Diff