diff --git a/src/SvelteAuth/auth.ts b/src/SvelteAuth/auth.ts new file mode 100644 index 0000000..7f4ace2 --- /dev/null +++ b/src/SvelteAuth/auth.ts @@ -0,0 +1,201 @@ +import type { GetSession, RequestHandler } from "@sveltejs/kit"; +import type { EndpointOutput, ServerRequest } from "@sveltejs/kit/types/endpoint"; +import type { Headers } from "@sveltejs/kit/types/helper"; +import cookie from "cookie"; +import * as jsonwebtoken from "jsonwebtoken"; +import type { JWT, Session } from "./interfaces"; +import type { Provider } from "./providers"; + +interface AuthConfig { + providers?: Provider[]; + callbacks?: AuthCallbacks; + jwtSecret?: string; + jwtExpiresIn?: string | number; +} + +interface AuthCallbacks { + signIn?: () => boolean | Promise; + jwt?: (token: JWT, profile?: any) => JWT | Promise; + session?: (token: JWT, session: Session) => Session | Promise; + redirect?: (url: string) => string | Promise; +} + +export class Auth { + constructor(private readonly config?: AuthConfig) {} + + getJwtSecret() { + if (this.config?.jwtSecret) { + return this.config?.jwtSecret; + } + + if (this.config?.providers?.length) { + const provs = this.config?.providers?.map((provider) => provider.id).join("+"); + return Buffer.from(provs).toString("base64"); + } + + return "svelte_auth_secret"; + } + + async getToken(headers: Headers) { + if (!headers.cookie) { + return null; + } + + const cookies = cookie.parse(headers.cookie); + + if (!cookies.svelteauthjwt) { + return null; + } + + let token: JWT; + try { + token = (jsonwebtoken.verify(cookies.svelteauthjwt, this.getJwtSecret()) || {}) as JWT; + } catch { + return null; + } + + if (this.config?.callbacks?.jwt) { + token = await this.config.callbacks.jwt(token); + } + + return token; + } + + getBaseUrl(host: string) { + return `http://${host}`; + } + + setToken(headers: Headers, newToken: JWT | any) { + const originalToken = this.getToken(headers); + + return { + ...(originalToken ?? {}), + ...newToken, + }; + } + + signToken(token: JWT) { + const opts = !token.exp + ? { + expiresIn: this.config?.jwtExpiresIn ?? "30d", + } + : {}; + const jwt = jsonwebtoken.sign(token, this.getJwtSecret(), opts); + return jwt; + } + + async getRedirectUrl(host: string, redirectUrl?: string) { + let redirect = redirectUrl || this.getBaseUrl(host); + if (this.config?.callbacks?.redirect) { + redirect = await this.config.callbacks.redirect(redirect); + } + return redirect; + } + + async handleProviderCallback( + request: ServerRequest, + provider: Provider, + ): Promise { + const { headers, host } = request; + const [profile, redirectUrl] = await provider.callback(request); + + let token = (await this.getToken(headers)) ?? { user: {} }; + if (this.config?.callbacks?.jwt) { + token = await this.config.callbacks.jwt(token, profile); + } else { + token = this.setToken(headers, { user: profile }); + } + + const jwt = this.signToken(token); + console.log(jwt); + const redirect = await this.getRedirectUrl(host, redirectUrl); + + return { + status: 302, + headers: { + "set-cookie": `svelteauthjwt=${jwt}; Path=/; HttpOnly`, + Location: redirect, + }, + }; + } + + async handleEndpoint(request: ServerRequest): Promise { + const { path, headers, method, host } = request; + + if (path === "/api/auth/signout") { + const token = this.setToken(headers, {}); + const jwt = this.signToken(token); + + if (method === "POST") { + return { + headers: { + "set-cookie": `svelteauthjwt=${jwt}; Path=/; HttpOnly`, + }, + body: { + signout: true, + }, + }; + } + + const redirect = await this.getRedirectUrl(host); + + return { + status: 302, + headers: { + "set-cookie": `svelteauthjwt=${jwt}; Path=/; HttpOnly`, + Location: redirect, + }, + }; + } + + const match = path.match(/\/api\/auth\/(?signin|callback)\/(?\w+)/); + + if (match) { + const provider = this.config?.providers?.find( + (provider) => provider.id === match.groups.provider, + ); + if (provider) { + if (match.groups.method === "signin") { + return await provider.signin(request); + } else { + return await this.handleProviderCallback(request, provider); + } + } + } + } + + get: RequestHandler = async (request) => { + const { path } = request; + + if (path === "/api/auth/csrf") { + return { body: "1234" }; // TODO: Generate real token + } else if (path === "/api/auth/session") { + const session = await this.getSession(request); + return { + body: { + session, + }, + }; + } + + return await this.handleEndpoint(request); + }; + + post: RequestHandler = async (request) => { + return await this.handleEndpoint(request); + }; + + getSession: GetSession = async ({ headers }) => { + const token = await this.getToken(headers); + + if (token) { + if (this.config?.callbacks?.session) { + return await this.config.callbacks.session(token, { user: token.user }); + } + + return { user: token.user }; + } + + return {}; + }; +} diff --git a/src/SvelteAuth/client/index.ts b/src/SvelteAuth/client/index.ts new file mode 100644 index 0000000..df0a8ef --- /dev/null +++ b/src/SvelteAuth/client/index.ts @@ -0,0 +1,2 @@ +export { signIn } from "./signIn"; +export { signOut } from "./signOut"; diff --git a/src/SvelteAuth/client/signIn.ts b/src/SvelteAuth/client/signIn.ts new file mode 100644 index 0000000..be97123 --- /dev/null +++ b/src/SvelteAuth/client/signIn.ts @@ -0,0 +1,38 @@ +import { goto } from "$app/navigation"; +import { page } from "$app/stores"; +import type { Page } from "@sveltejs/kit"; + +interface SignInConfig { + redirectUrl?: string; +} + +export async function signIn(provider: string, data?: any, config?: SignInConfig) { + if (data) { + const path = `/api/auth/callback/${provider}`; + const res = await fetch(path, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + return await res.json(); + } + + let redirectUrl: string; + if (config?.redirectUrl) { + redirectUrl = config.redirectUrl; + } else { + let $val: Page; + page.subscribe(($) => ($val = $))(); + redirectUrl = `${$val.host}${$val.path}?${$val.query}`; + } + + const queryData = { + redirect: redirectUrl, + }; + const query = new URLSearchParams(queryData); + const path = `/api/auth/login/${provider}?${query}`; + + return await goto(path); +} diff --git a/src/SvelteAuth/client/signOut.ts b/src/SvelteAuth/client/signOut.ts new file mode 100644 index 0000000..564ab69 --- /dev/null +++ b/src/SvelteAuth/client/signOut.ts @@ -0,0 +1,12 @@ +import { session as session$ } from "$app/stores"; + +export async function signOut() { + const res = await fetch("/api/auth/signout", { method: "POST" }); + const { signout } = await res.json(); + + fetch("/api/auth/session") + .then((res) => res.json()) + .then(session$.set); + + return signout === true; +} diff --git a/src/SvelteAuth/index.ts b/src/SvelteAuth/index.ts new file mode 100644 index 0000000..3223cc5 --- /dev/null +++ b/src/SvelteAuth/index.ts @@ -0,0 +1,2 @@ +export { Auth } from "./auth"; +export { Provider } from "./providers"; diff --git a/src/SvelteAuth/interfaces.ts b/src/SvelteAuth/interfaces.ts new file mode 100644 index 0000000..df133b4 --- /dev/null +++ b/src/SvelteAuth/interfaces.ts @@ -0,0 +1,13 @@ +export interface JWT { + user: User; + [key: string]: any; +} + +export interface User { + [key: string]: any; +} + +export interface Session { + user: User; + [key: string]: any; +} diff --git a/src/SvelteAuth/providers/base.ts b/src/SvelteAuth/providers/base.ts new file mode 100644 index 0000000..4d84d54 --- /dev/null +++ b/src/SvelteAuth/providers/base.ts @@ -0,0 +1,32 @@ +import type { EndpointOutput } from "@sveltejs/kit"; +import type { ServerRequest } from "@sveltejs/kit/types/endpoint"; +import type { CallbackResult } from "../types"; + +export interface ProviderConfig { + id?: string; + profile?: (profile: any, account: any) => any | Promise; +} + +export abstract class Provider { + id: string; + + constructor(protected readonly config: T) { + this.id = config.id; + } + + getUri(host: string, path: string) { + return `http://${host}${path}`; + } + + getCallbackUri(host: string) { + return this.getUri(host, `${"/api/auth/callback/"}${this.id}`); + } + + abstract signin = Record, Body = unknown>( + request: ServerRequest, + ): EndpointOutput | Promise; + + abstract callback = Record, Body = unknown>( + request: ServerRequest, + ): CallbackResult | Promise; +} diff --git a/src/SvelteAuth/providers/facebook.ts b/src/SvelteAuth/providers/facebook.ts new file mode 100644 index 0000000..c012b64 --- /dev/null +++ b/src/SvelteAuth/providers/facebook.ts @@ -0,0 +1,78 @@ +import type { ServerRequest } from "@sveltejs/kit/types/endpoint"; +import { OAuth2Provider, OAuth2ProviderConfig } from "./oauth2"; + +interface FacebookAuthProviderConfig extends OAuth2ProviderConfig { + clientId: string; + clientSecret: string; + userProfileFields?: string; + scope?: string; +} + +const defaultConfig: Partial = { + id: "facebook", + scope: "email public_profile user_link", + userProfileFields: + "id,name,first_name,last_name,middle_name,name_format,picture,short_name,email", +}; + +export class FacebookAuthProvider extends OAuth2Provider { + constructor(config: FacebookAuthProviderConfig) { + super({ + ...defaultConfig, + ...config, + }); + } + + getSigninUrl({ host }: ServerRequest, state: string) { + const endpoint = "https://www.facebook.com/v10.0/dialog/oauth"; + + const data = { + client_id: this.config.clientId, + scope: this.config.scope, + redirect_uri: this.getCallbackUri(host), + state, + }; + + const url = `${endpoint}?${new URLSearchParams(data)}`; + return url; + } + + async getTokens(code: string, redirectUri: string) { + const endpoint = "https://graph.facebook.com/v10.0/oauth/access_token"; + + const data = { + code, + client_id: this.config.clientId, + redirect_uri: redirectUri, + client_secret: this.config.clientSecret, + }; + + const res = await fetch(`${endpoint}?${new URLSearchParams(data)}`); + return await res.json(); + } + + async inspectToken(tokens: any) { + const endpoint = "https://graph.facebook.com/debug_token"; + + const data = { + input_token: tokens.access_token, + access_token: `${this.config.clientId}|${this.config.clientSecret}`, + }; + + const res = await fetch(`${endpoint}?${new URLSearchParams(data)}`); + return await res.json(); + } + + async getUserProfile(tokens: any) { + const inspectResult = await this.inspectToken(tokens); + const endpoint = `https://graph.facebook.com/v10.0/${inspectResult.data.user_id}`; + + const data = { + access_token: tokens.access_token, + fields: this.config.userProfileFields, + }; + + const res = await fetch(`${endpoint}?${new URLSearchParams(data)}`); + return await res.json(); + } +} diff --git a/src/SvelteAuth/providers/google.ts b/src/SvelteAuth/providers/google.ts new file mode 100644 index 0000000..0f44168 --- /dev/null +++ b/src/SvelteAuth/providers/google.ts @@ -0,0 +1,82 @@ +import type { ServerRequest } from "@sveltejs/kit/types/endpoint"; +import { OAuth2Provider, OAuth2ProviderConfig } from "./oauth2"; + +interface GoogleOAuthProviderConfig extends OAuth2ProviderConfig { + clientId: string; + clientSecret: string; + discoveryDocument?: string; + scope?: string; +} + +const defaultConfig: Partial = { + id: "google", + discoveryDocument: "https://accounts.google.com/.well-known/openid-configuration", + scope: "openid profile email", +}; + +export class GoogleOAuthProvider extends OAuth2Provider { + constructor(config: GoogleOAuthProviderConfig) { + super({ + ...defaultConfig, + ...config, + }); + } + + async getProviderMetadata() { + const res = await fetch(this.config.discoveryDocument); + const metadata = await res.json(); + return metadata; + } + + async getEndpoint(key: string) { + const metadata = await this.getProviderMetadata(); + return metadata[key] as string; + } + + async getSigninUrl({ host }: ServerRequest, state: string) { + const authorizationEndpoint = await this.getEndpoint("authorization_endpoint"); + + const data = { + response_type: "code", + client_id: this.config.clientId, + scope: this.config.scope, + redirect_uri: this.getCallbackUri(host), + state, + login_hint: "example@provider.com", + nonce: Math.round(Math.random() * 1000).toString(), // TODO: Generate random based on user values + }; + + const url = `${authorizationEndpoint}?${new URLSearchParams(data)}`; + return url; + } + + async getTokens(code: string, redirectUri: string) { + const tokenEndpoint = await this.getEndpoint("token_endpoint"); + + const data = { + code, + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + redirect_uri: redirectUri, + grant_type: "authorization_code", + }; + + const res = await fetch(tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + + return await res.json(); + } + + async getUserProfile(tokens: any) { + const userProfileEndpoint = await this.getEndpoint("userinfo_endpoint"); + const res = await fetch(userProfileEndpoint, { + headers: { Authorization: `${tokens.token_type} ${tokens.access_token}` }, + }); + return await res.json(); + } +} diff --git a/src/SvelteAuth/providers/index.ts b/src/SvelteAuth/providers/index.ts new file mode 100644 index 0000000..4e56d7a --- /dev/null +++ b/src/SvelteAuth/providers/index.ts @@ -0,0 +1,2 @@ +export { Provider } from "./base"; +export { GoogleOAuthProvider } from "./google"; diff --git a/src/SvelteAuth/providers/oauth2.ts b/src/SvelteAuth/providers/oauth2.ts new file mode 100644 index 0000000..16aa1a9 --- /dev/null +++ b/src/SvelteAuth/providers/oauth2.ts @@ -0,0 +1,59 @@ +import type { EndpointOutput, ServerRequest } from "@sveltejs/kit/types/endpoint"; +import type { CallbackResult } from "../types"; +import { Provider, ProviderConfig } from "./base"; + +export interface OAuth2ProviderConfig extends ProviderConfig { + profile?: (profile: any, tokens: any) => any | Promise; +} + +export abstract class OAuth2Provider extends Provider { + abstract getSigninUrl(request: ServerRequest, state: string): string | Promise; + abstract getTokens(code: string, redirectUri: string): any | Promise; + abstract getUserProfile(tokens: any): any | Promise; + + async signin(request: ServerRequest): Promise { + const { method, host, query } = request; + const state = [`redirect=${query.get("redirect") ?? this.getUri(host, "/")}`].join(","); + const base64State = Buffer.from(state).toString("base64"); + const url = await this.getSigninUrl(request, base64State); + + if (method === "POST") { + return { + body: { + redirect: url, + }, + }; + } + + return { + status: 302, + headers: { + Location: url, + }, + }; + } + + getStateValue(query: URLSearchParams, name: string) { + if (query.get("state")) { + const state = Buffer.from(query.get("state"), "base64").toString(); + return state + .split(",") + .find((state) => state.startsWith(`${name}=`)) + ?.replace(`${name}=`, ""); + } + } + + async callback({ query, host }: ServerRequest): Promise { + const code = query.get("code"); + const redirect = this.getStateValue(query, "redirect"); + + const tokens = await this.getTokens(code, this.getCallbackUri(host)); + let user = await this.getUserProfile(tokens); + + if (this.config.profile) { + user = await this.config.profile(user, tokens); + } + + return [user, redirect ?? this.getUri(host, "/")]; + } +} diff --git a/src/SvelteAuth/providers/reddit.ts b/src/SvelteAuth/providers/reddit.ts new file mode 100644 index 0000000..9304f95 --- /dev/null +++ b/src/SvelteAuth/providers/reddit.ts @@ -0,0 +1,119 @@ +import type { ServerRequest } from "@sveltejs/kit/types/endpoint"; +import { OAuth2Provider, OAuth2ProviderConfig } from "./oauth2"; + +interface RedditOAuthProviderConfig extends OAuth2ProviderConfig { + apiKey: string; + apiSecret: string; + scope?: string; + duration?: "temporary" | "permanent"; +} + +const defaultConfig: Partial = { + id: "reddit", + scope: "identity", + duration: "temporary", + profile: ({ + is_employee, + has_external_account, + snoovatar_img, + verified, + id, + over_18, + is_gold, + is_mod, + awarder_karma, + has_verified_email, + is_suspended, + icon_img, + pref_nightmode, + awardee_karma, + password_set, + link_karma, + total_karma, + name, + created, + created_utc, + comment_karma, + }) => ({ + is_employee, + has_external_account, + snoovatar_img, + verified, + id, + over_18, + is_gold, + is_mod, + awarder_karma, + has_verified_email, + is_suspended, + icon_img, + pref_nightmode, + awardee_karma, + password_set, + link_karma, + total_karma, + name, + created, + created_utc, + comment_karma, + }), +}; + +export class RedditOAuthProvider extends OAuth2Provider { + constructor(config: RedditOAuthProviderConfig) { + super({ + ...defaultConfig, + ...config, + }); + } + + async getSigninUrl({ host }: ServerRequest, state: string) { + const endpoint = "https://www.reddit.com/api/v1/authorize"; + + const data = { + client_id: this.config.apiKey, + response_type: "code", + state, + redirect_uri: this.getCallbackUri(host), + duration: this.config.duration, + scope: this.config.scope, + }; + + const url = `${endpoint}?${new URLSearchParams(data)}`; + return url; + } + + async getTokens(code: string, redirectUri: string) { + const endpoint = "https://www.reddit.com/api/v1/access_token"; + + const data = { + code, + redirect_uri: redirectUri, + grant_type: "authorization_code", + }; + const body = Object.entries(data) + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join("&"); + + const res = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: + "Basic " + + Buffer.from(`${this.config.apiKey}:${this.config.apiSecret}`).toString("base64"), + }, + body, + }); + + return await res.json(); + } + + async getUserProfile(tokens: any) { + const endpoint = "https://oauth.reddit.com/api/v1/me"; + const res = await fetch(endpoint, { + headers: { Authorization: `${tokens.token_type} ${tokens.access_token}` }, + }); + return await res.json(); + } +} diff --git a/src/SvelteAuth/providers/twitter.ts b/src/SvelteAuth/providers/twitter.ts new file mode 100644 index 0000000..934beac --- /dev/null +++ b/src/SvelteAuth/providers/twitter.ts @@ -0,0 +1,87 @@ +import type { ServerRequest } from "@sveltejs/kit/types/endpoint"; +import type { CallbackResult } from "../types"; +import { OAuth2Provider, OAuth2ProviderConfig } from "./oauth2"; + +interface TwitterAuthProviderConfig extends OAuth2ProviderConfig { + apiKey: string; + apiSecret: string; +} + +const defaultConfig: Partial = { + id: "twitter", +}; + +export class TwitterAuthProvider extends OAuth2Provider { + constructor(config: TwitterAuthProviderConfig) { + super({ + ...defaultConfig, + ...config, + }); + } + + async getRequestToken(host: string) { + const endpoint = "https://api.twitter.com/oauth/request_token"; + + const data = { + oauth_callback: encodeURIComponent(this.getCallbackUri(host)), + oauth_consumer_key: this.config.apiKey, + }; + + const res = await fetch(`${endpoint}?${new URLSearchParams(data)}`, { method: "POST" }); + const { oauth_token, oauth_token_secret, oauth_callback_confirmed } = await res.json(); + + return { + oauthToken: oauth_token, + oauthTokenSecret: oauth_token_secret, + oauthCallbackConfirmed: oauth_callback_confirmed, + }; + } + + async getSigninUrl({ host }: ServerRequest) { + const endpoint = "https://api.twitter.com/oauth/authorize"; + + const { oauthToken } = await this.getRequestToken(host); + + const data = { + oauth_token: oauthToken, + }; + + const url = `${endpoint}?${new URLSearchParams(data)}`; + return url; + } + + async getTokens(oauthToken: string, oauthVerifier: string) { + const endpoint = "https://api.twitter.com/oauth/access_token"; + + const data = { + oauth_consumer_key: this.config.apiKey, + oauth_token: oauthToken, + oauth_verifier: oauthVerifier, + }; + + const res = await fetch(`${endpoint}?${new URLSearchParams(data)}`, { method: "POST" }); + return await res.json(); + } + + async getUserProfile({ oauth_token, oauth_token_secret: _ }: any) { + const endpoint = "https://api.twitter.com/1.1/account/verify_credentials.json"; + + const res = await fetch(endpoint, { headers: { Authorization: `Bearer ${oauth_token}` } }); + return await res.json(); + } + + async callback({ query, host }: ServerRequest): Promise { + const oauthToken = query.get("oauth_token"); + const oauthVerifier = query.get("oauth_verifier"); + const redirect = this.getStateValue(query, "redirect"); + + const tokens = await this.getTokens(oauthToken, oauthVerifier); + let user = await this.getUserProfile(tokens); + + if (this.config.profile) { + user = await this.config.profile(user, tokens); + } + + return [user, redirect ?? this.getUri(host, "/")]; + } +} diff --git a/src/SvelteAuth/types.ts b/src/SvelteAuth/types.ts new file mode 100644 index 0000000..f8fddc4 --- /dev/null +++ b/src/SvelteAuth/types.ts @@ -0,0 +1,2 @@ +export type Profile = any; +export type CallbackResult = [Profile, string | null];