[ENHANCEMENT] OAuth Base Provider (#12)

*  Inject auth instance into provider `signin()` and `callback()` methods

Add generic OAuth provider to implement with simple config.

* 🐛 Fix storing multiple social connections in demo app

*  Create `apiKey` and `apiSecret` aliases for Reddit provider

* ⬆️ Reinstall local dep

* 🏷️ Remove comments / use `OAuth2ProviderConfig` for `GoogleOAuth2Provider` types
This commit is contained in:
Dan6erbond 2021-05-23 22:09:57 +02:00 committed by GitHub
parent 6397de8a45
commit b4f7688377
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 189 additions and 232 deletions

View File

@ -46,7 +46,7 @@ export const appAuth = new SvelteKitAuth({
...token,
user: {
...token.user,
connections: { [provider]: account },
connections: { ...token.user.connections, [provider]: account },
},
};
}

View File

@ -2095,6 +2095,12 @@ simple-swizzle@^0.2.2:
dependencies:
is-arrayish "^0.3.1"
"sk-auth@file:..":
version "0.1.1"
dependencies:
cookie "^0.4.1"
jsonwebtoken "^8.5.1"
"sk-auth@file:../":
version "0.1.1"
dependencies:

View File

@ -105,7 +105,7 @@ export class Auth {
provider: Provider,
): Promise<EndpointOutput> {
const { headers, host } = request;
const [profile, redirectUrl] = await provider.callback(request);
const [profile, redirectUrl] = await provider.callback(request, this);
let token = (await this.getToken(headers)) ?? { user: {} };
if (this.config?.callbacks?.jwt) {
@ -129,7 +129,7 @@ export class Auth {
async handleEndpoint(request: ServerRequest): Promise<EndpointOutput> {
const { path, headers, method, host } = request;
if (path === this.getPath("signout")) {
if (path === this.getPath("signout", host)) {
const token = this.setToken(headers, {});
const jwt = this.signToken(token);
@ -163,7 +163,7 @@ export class Auth {
);
if (provider) {
if (match.groups.method === "signin") {
return await provider.signin(request);
return await provider.signin(request, this);
} else {
return await this.handleProviderCallback(request, provider);
}

View File

@ -1,5 +1,6 @@
import type { EndpointOutput } from "@sveltejs/kit";
import type { ServerRequest } from "@sveltejs/kit/types/endpoint";
import type { Auth } from "../auth";
import type { CallbackResult } from "../types";
export interface ProviderConfig {
@ -14,19 +15,25 @@ export abstract class Provider<T extends ProviderConfig = ProviderConfig> {
this.id = config.id!;
}
getUri(host: string, path: string) {
getUri(svelteKitAuth: Auth, path: string, host?: string) {
return `http://${host}${path}`;
}
getCallbackUri(host: string) {
return this.getUri(host, `${"/api/auth/callback/"}${this.id}`);
getCallbackUri(svelteKitAuth: Auth, host?: string) {
return svelteKitAuth.getPath(`${"/api/auth/callback/"}${this.id}`, host);
}
getSigninUri(svelteKitAuth: Auth, host?: string) {
return svelteKitAuth.getPath(`${"/api/auth/signin/"}${this.id}`, host);
}
abstract signin<Locals extends Record<string, any> = Record<string, any>, Body = unknown>(
request: ServerRequest<Locals, Body>,
svelteKitAuth: Auth,
): EndpointOutput | Promise<EndpointOutput>;
abstract callback<Locals extends Record<string, any> = Record<string, any>, Body = unknown>(
request: ServerRequest<Locals, Body>,
svelteKitAuth: Auth,
): CallbackResult | Promise<CallbackResult>;
}

View File

@ -1,78 +1,37 @@
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;
interface FacebookOAuth2ProviderConfig extends OAuth2ProviderConfig {
userProfileFields?: string | string[];
}
const defaultConfig: Partial<FacebookAuthProviderConfig> = {
const defaultConfig: Partial<FacebookOAuth2ProviderConfig> = {
id: "facebook",
scope: "email public_profile user_link",
userProfileFields:
"id,name,first_name,last_name,middle_name,name_format,picture,short_name,email",
scope: ["email", "public_profile", "user_link"],
userProfileFields: [
"id",
"name",
"first_name",
"last_name",
"middle_name",
"name_format",
"picture",
"short_name",
"email",
],
profileUrl: "https://graph.facebook.com/me",
};
export class FacebookAuthProvider extends OAuth2Provider<FacebookAuthProviderConfig> {
constructor(config: FacebookAuthProviderConfig) {
export class FacebookOAuth2Provider extends OAuth2Provider<FacebookOAuth2ProviderConfig> {
constructor(config: FacebookOAuth2ProviderConfig) {
const userProfileFields = config.userProfileFields || defaultConfig.userProfileFields;
const profileUrl = `${config.profileUrl || defaultConfig.profileUrl}?${
Array.isArray(userProfileFields) ? userProfileFields.join(",") : userProfileFields
}`;
super({
...defaultConfig,
profileUrl,
...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();
}
}

View File

@ -1,82 +1,18 @@
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<GoogleOAuthProviderConfig> = {
const defaultConfig: Partial<OAuth2ProviderConfig> = {
id: "google",
discoveryDocument: "https://accounts.google.com/.well-known/openid-configuration",
scope: "openid profile email",
scope: ["openid", "profile", "email"],
accessTokenUrl: "https://accounts.google.com/o/oauth2/token",
authorizationUrl: "https://accounts.google.com/o/oauth2/auth?response_type=code",
profileUrl: "https://openidconnect.googleapis.com/v1/userinfo",
};
export class GoogleOAuthProvider extends OAuth2Provider<GoogleOAuthProviderConfig> {
constructor(config: GoogleOAuthProviderConfig) {
export class GoogleOAuth2Provider extends OAuth2Provider {
constructor(config: OAuth2ProviderConfig) {
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();
}
}

View File

@ -1,6 +1,6 @@
export { Provider } from "./base";
export { GoogleOAuthProvider } from "./google";
export { GoogleOAuth2Provider as GoogleOAuthProvider } from "./google";
export { TwitterAuthProvider } from "./twitter";
export { FacebookAuthProvider } from "./facebook";
export { OAuth2Provider } from "./oauth2";
export { RedditOAuthProvider } from "./reddit";
export { FacebookOAuth2Provider as FacebookAuthProvider } from "./facebook";
export { OAuth2BaseProvider as OAuth2Provider } from "./oauth2.base";
export { RedditOAuth2Provider as RedditOAuthProvider } from "./reddit";

View File

@ -0,0 +1,60 @@
import type { EndpointOutput, ServerRequest } from "@sveltejs/kit/types/endpoint";
import type { Auth } from "../auth";
import type { CallbackResult } from "../types";
import { Provider, ProviderConfig } from "./base";
export interface OAuth2BaseProviderConfig extends ProviderConfig {
profile?: (profile: any, tokens: any) => any | Promise<any>;
}
export abstract class OAuth2BaseProvider<T extends OAuth2BaseProviderConfig> extends Provider<T> {
abstract getAuthorizationUrl(request: ServerRequest, auth: Auth, state: string): string | Promise<string>;
abstract getTokens(code: string, redirectUri: string): any | Promise<any>;
abstract getUserProfile(tokens: any): any | Promise<any>;
async signin(request: ServerRequest, auth: Auth): Promise<EndpointOutput> {
const { method, host, query } = request;
const state = [`redirect=${query.get("redirect") ?? this.getUri(auth, host, "/")}`].join(",");
const base64State = Buffer.from(state).toString("base64");
const url = await this.getAuthorizationUrl(request, auth, 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, auth: Auth): Promise<CallbackResult> {
const code = query.get("code");
const redirect = this.getStateValue(query, "redirect");
const tokens = await this.getTokens(code!, this.getCallbackUri(auth, host));
let user = await this.getUserProfile(tokens);
if (this.config.profile) {
user = await this.config.profile(user, tokens);
}
return [user, redirect ?? this.getUri(auth, host, "/")];
}
}

View File

@ -1,59 +1,69 @@
import type { EndpointOutput, ServerRequest } from "@sveltejs/kit/types/endpoint";
import type { CallbackResult } from "../types";
import { Provider, ProviderConfig } from "./base";
import type { ServerRequest } from "@sveltejs/kit/types/endpoint";
import type { Auth } from "../auth";
import { OAuth2BaseProvider, OAuth2BaseProviderConfig } from "./oauth2.base";
export interface OAuth2ProviderConfig extends ProviderConfig {
profile?: (profile: any, tokens: any) => any | Promise<any>;
export interface OAuth2ProviderConfig extends OAuth2BaseProviderConfig {
accessTokenUrl?: string;
authorizationUrl?: string;
profileUrl?: string;
clientId?: string;
clientSecret?: string;
scope: string | string[];
headers?: any;
authorizationParams?: any;
params: any;
grantType?: string;
responseType?: string;
}
export abstract class OAuth2Provider<T extends OAuth2ProviderConfig> extends Provider<T> {
abstract getSigninUrl(request: ServerRequest, state: string): string | Promise<string>;
abstract getTokens(code: string, redirectUri: string): any | Promise<any>;
abstract getUserProfile(tokens: any): any | Promise<any>;
const defaultConfig: Partial<OAuth2ProviderConfig> = {
responseType: "code",
grantType: "authorization_code",
};
async signin(request: ServerRequest): Promise<EndpointOutput> {
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);
export class OAuth2Provider<
T extends OAuth2ProviderConfig = OAuth2ProviderConfig,
> extends OAuth2BaseProvider<T> {
constructor(config: T) {
super({
...defaultConfig,
...config,
});
}
if (method === "POST") {
return {
body: {
redirect: url,
},
};
}
return {
status: 302,
headers: {
Location: url,
},
getAuthorizationUrl({ host }: ServerRequest, auth: Auth, state: string) {
const data = {
state,
response_type: this.config.responseType,
client_id: this.config.clientId,
scope: Array.isArray(this.config.scope) ? this.config.scope.join(" ") : this.config.scope,
redirect_uri: this.getCallbackUri(auth, host),
nonce: Math.round(Math.random() * 1000).toString(), // TODO: Generate random based on user values
...(this.config.authorizationParams ?? {}),
};
const url = `${this.config.authorizationUrl}?${new URLSearchParams(data)}`;
return 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 getTokens(code: string, redirectUri: string) {
const data = {
code,
grant_type: this.config.grantType,
client_id: this.config.clientId,
redirect_uri: redirectUri,
client_secret: this.config.clientSecret,
...(this.config.params ?? {}),
};
const res = await fetch(`${this.config.accessTokenUrl}?${new URLSearchParams(data)}`);
return await res.json();
}
async callback({ query, host }: ServerRequest): Promise<CallbackResult> {
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, "/")];
async getUserProfile(tokens: any) {
const res = await fetch(this.config.profileUrl!, {
headers: { Authorization: `${tokens.token_type} ${tokens.access_token}` },
});
return await res.json();
}
}

View File

@ -1,11 +1,9 @@
import type { ServerRequest } from "@sveltejs/kit/types/endpoint";
import { OAuth2Provider, OAuth2ProviderConfig } from "./oauth2";
interface RedditOAuthProviderConfig extends OAuth2ProviderConfig {
interface RedditOAuth2ProviderConfig extends OAuth2ProviderConfig {
duration?: "temporary" | "permanent";
apiKey: string;
apiSecret: string;
scope?: string;
duration?: "temporary" | "permanent";
}
const redditProfileHandler = ({
@ -54,46 +52,35 @@ const redditProfileHandler = ({
comment_karma,
});
const defaultConfig: Partial<RedditOAuthProviderConfig> = {
const defaultConfig: Partial<RedditOAuth2ProviderConfig> = {
id: "reddit",
scope: "identity",
duration: "temporary",
profile: redditProfileHandler,
authorizationUrl: "https://www.reddit.com/api/v1/authorize",
accessTokenUrl: "https://www.reddit.com/api/v1/access_token",
profileUrl: "https://oauth.reddit.com/api/v1/me",
};
export class RedditOAuthProvider extends OAuth2Provider<RedditOAuthProviderConfig> {
constructor(config: RedditOAuthProviderConfig) {
export class RedditOAuth2Provider extends OAuth2Provider<RedditOAuth2ProviderConfig> {
constructor(config: RedditOAuth2ProviderConfig) {
super({
...defaultConfig,
...config,
clientId: config.apiKey,
clientSecret: config.apiSecret,
});
}
static profileHandler = redditProfileHandler;
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 endpoint = this.config.accessTokenUrl!;
const data = {
code,
redirect_uri: redirectUri,
grant_type: "authorization_code",
grant_type: this.config.grantType!,
};
const body = Object.entries(data)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
@ -105,19 +92,11 @@ export class RedditOAuthProvider extends OAuth2Provider<RedditOAuthProviderConfi
"Content-Type": "application/x-www-form-urlencoded",
Authorization:
"Basic " +
Buffer.from(`${this.config.apiKey}:${this.config.apiSecret}`).toString("base64"),
Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).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();
}
}

View File

@ -1,8 +1,8 @@
import type { ServerRequest } from "@sveltejs/kit/types/endpoint";
import type { CallbackResult } from "../types";
import { OAuth2Provider, OAuth2ProviderConfig } from "./oauth2";
import { OAuth2BaseProvider, OAuth2BaseProviderConfig } from "./oauth2.base";
interface TwitterAuthProviderConfig extends OAuth2ProviderConfig {
interface TwitterAuthProviderConfig extends OAuth2BaseProviderConfig {
apiKey: string;
apiSecret: string;
}
@ -11,7 +11,7 @@ const defaultConfig: Partial<TwitterAuthProviderConfig> = {
id: "twitter",
};
export class TwitterAuthProvider extends OAuth2Provider<TwitterAuthProviderConfig> {
export class TwitterAuthProvider extends OAuth2BaseProvider<TwitterAuthProviderConfig> {
constructor(config: TwitterAuthProviderConfig) {
super({
...defaultConfig,
@ -37,7 +37,7 @@ export class TwitterAuthProvider extends OAuth2Provider<TwitterAuthProviderConfi
};
}
async getSigninUrl({ host }: ServerRequest) {
async getAuthorizationUrl({ host }: ServerRequest) {
const endpoint = "https://api.twitter.com/oauth/authorize";
const { oauthToken } = await this.getRequestToken(host);