Mock OAuth server (#138)

This commit is contained in:
Konsti Wohlwend 2024-07-20 17:29:04 -07:00 committed by GitHub
parent fd6f6c6d93
commit 84960ec9ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 884 additions and 75 deletions

View File

@ -78,6 +78,15 @@ jobs:
tail: true
wait-for: 30s
log-output-if: true
- name: Start oauth-mock-server in background
uses: JarvusInnovations/background-action@v1.0.7
with:
run: pnpm run start:oauth-mock-server --log-order=stream &
wait-on: |
http://localhost:8102
tail: true
wait-for: 30s
log-output-if: true
- name: Run tests
run: pnpm test

View File

@ -23,6 +23,7 @@
"nextjs",
"Nicifiable",
"nicify",
"oidc",
"openapi",
"Proxied",
"reqs",

View File

@ -2,8 +2,11 @@
STACK_BASE_URL=# enter the URL of the backend here. For local development, use `http://localhost:8102`.
STACK_SERVER_SECRET=# enter a secret key generated by `pnpm generate-keys` here. This is used to sign the JWT tokens.
# OAuth mock provider settings
STACK_OAUTH_MOCK_URL=# enter the URL of the mock OAuth provider here. For local development, use `http://localhost:8107`.
# OAuth shared keys
# Can be omitted if shared OAuth keys are not needed
# Can be set to MOCK to use mock OAuth providers
STACK_GITHUB_CLIENT_ID=# client
STACK_GITHUB_CLIENT_SECRET=# client secret
STACK_GOOGLE_CLIENT_ID=# client id

View File

@ -1,6 +1,19 @@
STACK_BASE_URL=http://localhost:8102
STACK_SERVER_SECRET=23-wuNpik0gIW4mruTz25rbIvhuuvZFrLOLtL7J4tyo
STACK_OAUTH_MOCK_URL=http://localhost:8107
STACK_GITHUB_CLIENT_ID=MOCK
STACK_GITHUB_CLIENT_SECRET=MOCK
STACK_GOOGLE_CLIENT_ID=MOCK
STACK_GOOGLE_CLIENT_SECRET=MOCK
STACK_FACEBOOK_CLIENT_ID=MOCK
STACK_FACEBOOK_CLIENT_SECRET=MOCK
STACK_MICROSOFT_CLIENT_ID=MOCK
STACK_MICROSOFT_CLIENT_SECRET=MOCK
STACK_SPOTIFY_CLIENT_ID=MOCK
STACK_SPOTIFY_CLIENT_SECRET=MOCK
STACK_DATABASE_CONNECTION_STRING=postgres://postgres:password@localhost:5432/stackframe
STACK_DIRECT_DATABASE_CONNECTION_STRING=postgres://postgres:password@localhost:5432/stackframe

View File

@ -83,7 +83,8 @@ export const GET = createSmartRouteHandler({
const innerCodeVerifier = generators.codeVerifier();
const innerState = generators.state();
const oauthUrl = getProvider(provider).getAuthorizationUrl({
const providerObj = await getProvider(provider);
const oauthUrl = providerObj.getAuthorizationUrl({
codeVerifier: innerCodeVerifier,
state: innerState,
extraScope: query.provider_scope,
@ -115,7 +116,7 @@ export const GET = createSmartRouteHandler({
// prevent CSRF by keeping track of the inner state in cookies
// the callback route must ensure that the inner state cookie is set
cookies().set(
"stack-oauth-" + innerState,
"stack-oauth-inner-" + innerState,
"true",
{
httpOnly: true,

View File

@ -39,8 +39,9 @@ export const GET = createSmartRouteHandler({
headers: yupMixed().required(),
}),
async handler({ params, query }, fullReq) {
const cookieInfo = cookies().get("stack-oauth-" + query.state);
cookies().delete("stack-oauth-" + query.state);
const innerState = query.state ?? "";
const cookieInfo = cookies().get("stack-oauth-inner-" + innerState);
cookies().delete("stack-oauth-inner-" + query.state);
if (cookieInfo?.value !== 'true') {
throw new StatusError(StatusError.BadRequest, "stack-oauth cookie not found");
@ -88,13 +89,11 @@ export const GET = createSmartRouteHandler({
throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled();
}
const userInfo = await getProvider(provider).getCallback({
const providerObj = await getProvider(provider);
const userInfo = await providerObj.getCallback({
codeVerifier: innerCodeVerifier,
state: query.state ?? throwErr(new StatusError(StatusError.BadRequest, "Must provide state in query")),
callbackParams: {
code: query.code ?? throwErr(new StatusError(StatusError.BadRequest, "Must provide code in query")),
state: query.state ?? throwErr(new StatusError(StatusError.BadRequest, "Must provide state in query")),
}
state: innerState,
callbackParams: query,
});
if (type === "link") {
@ -152,7 +151,7 @@ export const GET = createSmartRouteHandler({
oAuthProviderConfigId: provider.id,
refreshToken: userInfo.refreshToken,
providerAccountId: userInfo.accountId,
scopes: extractScopes(getProvider(provider).scope + " " + providerScope),
scopes: extractScopes(providerObj.scope + " " + providerScope),
}
});
}

View File

@ -1,7 +1,7 @@
import OAuth2Server from "@node-oauth/oauth2-server";
import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { OAuthModel } from "./model";
import { OAuthBaseProvider } from "./providers/base";
import { FacebookProvider } from "./providers/facebook";
@ -9,6 +9,7 @@ import { GithubProvider } from "./providers/github";
import { GoogleProvider } from "./providers/google";
import { MicrosoftProvider } from "./providers/microsoft";
import { SpotifyProvider } from "./providers/spotify";
import { MockProvider } from "./providers/mock";
const _providers = {
github: GithubProvider,
@ -18,6 +19,8 @@ const _providers = {
spotify: SpotifyProvider,
} as const;
const mockProvider = MockProvider;
const _getEnvForProvider = (provider: keyof typeof _providers) => {
return {
clientId: getEnvVariable(`STACK_${provider.toUpperCase()}_CLIENT_ID`),
@ -25,14 +28,23 @@ const _getEnvForProvider = (provider: keyof typeof _providers) => {
};
};
export function getProvider(provider: ProjectsCrud['Admin']['Read']['config']['oauth_providers'][number]): OAuthBaseProvider {
export async function getProvider(provider: ProjectsCrud['Admin']['Read']['config']['oauth_providers'][number]): Promise<OAuthBaseProvider> {
if (provider.type === 'shared') {
return new _providers[provider.id]({
clientId: _getEnvForProvider(provider.id).clientId,
clientSecret: _getEnvForProvider(provider.id).clientSecret,
});
const clientId = _getEnvForProvider(provider.id).clientId;
const clientSecret = _getEnvForProvider(provider.id).clientSecret;
if (clientId === "MOCK") {
if (clientSecret !== "MOCK") {
throw new StackAssertionError("If OAuth provider client ID is set to MOCK, then client secret must also be set to MOCK");
}
return await mockProvider.create(provider.id);
} else {
return await _providers[provider.id].create({
clientId,
clientSecret,
});
}
} else {
return new _providers[provider.id]({
return await _providers[provider.id].create({
clientId: provider.client_id || throwErr("Client ID is required for standard providers"),
clientSecret: provider.client_secret || throwErr("Client secret is required for standard providers"),
});

View File

@ -4,28 +4,39 @@ import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"
import { mergeScopeStrings } from "@stackframe/stack-shared/dist/utils/strings";
export abstract class OAuthBaseProvider {
issuer: Issuer;
scope: string;
oauthClient: Client;
redirectUri: string;
constructor(
public readonly oauthClient: Client,
public readonly scope: string,
public readonly redirectUri: string,
) {}
constructor(options: {
issuer: string,
authorizationEndpoint: string,
tokenEndpoint: string,
userinfoEndpoint?: string,
clientId: string,
clientSecret: string,
redirectUri: string,
baseScope: string,
}) {
this.issuer = new Issuer({
protected static async createConstructorArgs(options:
& {
clientId: string,
clientSecret: string,
redirectUri: string,
baseScope: string,
isMock?: boolean,
}
& (
| {
issuer: string,
authorizationEndpoint: string,
tokenEndpoint: string,
userinfoEndpoint?: string,
}
| {
discoverFromUrl: string,
}
)
) {
const issuer = "discoverFromUrl" in options ? await Issuer.discover(options.discoverFromUrl) : new Issuer({
issuer: options.issuer,
authorization_endpoint: options.authorizationEndpoint,
token_endpoint: options.tokenEndpoint,
userinfo_endpoint: options.userinfoEndpoint,
});
this.oauthClient = new this.issuer.Client({
const oauthClient = new issuer.Client({
client_id: options.clientId,
client_secret: options.clientSecret,
redirect_uri: options.redirectUri,
@ -33,21 +44,20 @@ export abstract class OAuthBaseProvider {
});
// facebook always return an id_token even in the OAuth2 flow, which is not supported by openid-client
const oldGrant = this.oauthClient.grant;
const oldGrant = oauthClient.grant;
if (!(oldGrant as any)) {
// it seems that on Sentry, this was undefined in one scenario, so let's log some data to help debug if it happens again
// not sure if that is actually what was going on? the error log has very few details
// https://stackframe-pw.sentry.io/issues/5515577938
throw new StackAssertionError("oldGrant is undefined for some reason — that should never happen!", { options, oauthClient: this.oauthClient });
throw new StackAssertionError("oldGrant is undefined for some reason — that should never happen!", { options, oauthClient });
}
this.oauthClient.grant = async function (params) {
oauthClient.grant = async function (params) {
const grant = await oldGrant.call(this, params);
delete grant.id_token;
return grant;
};
this.redirectUri = options.redirectUri;
this.scope = options.baseScope;
return [oauthClient, options.baseScope, options.redirectUri] as const;
}
getAuthorizationUrl(options: {
@ -71,14 +81,14 @@ export abstract class OAuthBaseProvider {
state: string,
}): Promise<OAuthUserInfo> {
let tokenSet;
const params = {
code_verifier: options.codeVerifier,
state: options.state,
};
try {
const params = {
code_verifier: options.codeVerifier,
state: options.state,
};
tokenSet = await this.oauthClient.oauthCallback(this.redirectUri, options.callbackParams, params);
} catch (error) {
throw new StackAssertionError("OAuth callback failed", undefined, { cause: error });
throw new StackAssertionError(`Inner OAuth callback failed due to error: ${error}`, undefined, { cause: error });
}
if (!tokenSet.access_token) {
throw new StackAssertionError("No access token received", { tokenSet });

View File

@ -4,18 +4,24 @@ import { OAuthUserInfo, validateUserInfo } from "../utils";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
export class FacebookProvider extends OAuthBaseProvider {
constructor(options: {
private constructor(
...args: ConstructorParameters<typeof OAuthBaseProvider>
) {
super(...args);
}
static async create(options: {
clientId: string,
clientSecret: string,
}) {
super({
return new FacebookProvider(...await OAuthBaseProvider.createConstructorArgs({
issuer: "https://www.facebook.com",
authorizationEndpoint: "https://facebook.com/v20.0/dialog/oauth/",
tokenEndpoint: "https://graph.facebook.com/v20.0/oauth/access_token",
redirectUri: getEnvVariable("STACK_BASE_URL") + "/api/v1/auth/oauth/callback/facebook",
baseScope: "public_profile email",
...options
});
}));
}
async postProcessUserInfo(tokenSet: TokenSet): Promise<OAuthUserInfo> {

View File

@ -4,11 +4,17 @@ import { OAuthUserInfo, validateUserInfo } from "../utils";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
export class GithubProvider extends OAuthBaseProvider {
constructor(options: {
private constructor(
...args: ConstructorParameters<typeof OAuthBaseProvider>
) {
super(...args);
}
static async create(options: {
clientId: string,
clientSecret: string,
}) {
super({
return new GithubProvider(...await OAuthBaseProvider.createConstructorArgs({
issuer: "https://github.com",
authorizationEndpoint: "https://github.com/login/oauth/authorize",
tokenEndpoint: "https://github.com/login/oauth/access_token",
@ -16,7 +22,7 @@ export class GithubProvider extends OAuthBaseProvider {
redirectUri: getEnvVariable("STACK_BASE_URL") + "/api/v1/auth/oauth/callback/github",
baseScope: "user:email",
...options,
});
}));
}
async postProcessUserInfo(tokenSet: TokenSet): Promise<OAuthUserInfo> {

View File

@ -4,11 +4,17 @@ import { OAuthUserInfo, validateUserInfo } from "../utils";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
export class GoogleProvider extends OAuthBaseProvider {
constructor(options: {
private constructor(
...args: ConstructorParameters<typeof OAuthBaseProvider>
) {
super(...args);
}
static async create(options: {
clientId: string,
clientSecret: string,
}) {
super({
return new GoogleProvider(...await OAuthBaseProvider.createConstructorArgs({
issuer: "https://accounts.google.com",
authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
tokenEndpoint: "https://oauth2.googleapis.com/token",
@ -16,7 +22,7 @@ export class GoogleProvider extends OAuthBaseProvider {
redirectUri: getEnvVariable("STACK_BASE_URL") + "/api/v1/auth/oauth/callback/google",
baseScope: "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile",
...options,
});
}));
}
async postProcessUserInfo(tokenSet: TokenSet): Promise<OAuthUserInfo> {

View File

@ -4,18 +4,24 @@ import { OAuthUserInfo, validateUserInfo } from "../utils";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
export class MicrosoftProvider extends OAuthBaseProvider {
constructor(options: {
private constructor(
...args: ConstructorParameters<typeof OAuthBaseProvider>
) {
super(...args);
}
static async create(options: {
clientId: string,
clientSecret: string,
}) {
super({
return new MicrosoftProvider(...await OAuthBaseProvider.createConstructorArgs({
issuer: "https://login.microsoftonline.com",
authorizationEndpoint: "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize",
tokenEndpoint: "https://login.microsoftonline.com/consumers/oauth2/v2.0/token",
redirectUri: getEnvVariable("STACK_BASE_URL") + "/api/v1/auth/oauth/callback/microsoft",
baseScope: "User.Read",
...options,
});
}));
}
async postProcessUserInfo(tokenSet: TokenSet): Promise<OAuthUserInfo> {

View File

@ -0,0 +1,36 @@
import { TokenSet } from "openid-client";
import { OAuthBaseProvider } from "./base";
import { OAuthUserInfo, validateUserInfo } from "../utils";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
export class MockProvider extends OAuthBaseProvider {
constructor(
...args: ConstructorParameters<typeof OAuthBaseProvider>
) {
super(...args);
}
static async create(providerId: string) {
return new MockProvider(...await OAuthBaseProvider.createConstructorArgs({
discoverFromUrl: getEnvVariable("STACK_OAUTH_MOCK_URL"),
redirectUri: `${getEnvVariable("STACK_BASE_URL")}/api/v1/auth/oauth/callback/${providerId}`,
baseScope: "openid",
isMock: true,
clientId: providerId,
clientSecret: "MOCK-SERVER-SECRET",
}));
}
async postProcessUserInfo(tokenSet: TokenSet): Promise<OAuthUserInfo> {
const rawUserInfo = await this.oauthClient.userinfo(tokenSet);
return validateUserInfo({
accountId: rawUserInfo.sub,
displayName: rawUserInfo.name,
email: rawUserInfo.sub,
profileImageUrl: rawUserInfo.picture,
accessToken: tokenSet.access_token,
refreshToken: tokenSet.refresh_token,
});
}
}

View File

@ -4,18 +4,24 @@ import { OAuthUserInfo, validateUserInfo } from "../utils";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
export class SpotifyProvider extends OAuthBaseProvider {
constructor(options: {
private constructor(
...args: ConstructorParameters<typeof OAuthBaseProvider>
) {
super(...args);
}
static async create(options: {
clientId: string,
clientSecret: string,
}) {
super({
return new SpotifyProvider(...await OAuthBaseProvider.createConstructorArgs({
issuer: "https://accounts.spotify.com",
authorizationEndpoint: "https://accounts.spotify.com/authorize",
tokenEndpoint: "https://accounts.spotify.com/api/token",
redirectUri: getEnvVariable("STACK_BASE_URL") + "/api/v1/auth/oauth/callback/spotify",
baseScope: "user-read-email user-read-private",
...options,
});
}));
}
async postProcessUserInfo(tokenSet: TokenSet): Promise<OAuthUserInfo> {

View File

@ -129,7 +129,7 @@ export const GET = deprecatedSmartRouteHandler(async (req: NextRequest, options:
});
cookies().set(
"stack-oauth-" + innerState.slice(0, 8),
"stack-oauth-inner-" + innerState.slice(0, 8),
outerInfo.id,
{
httpOnly: true,

View File

@ -45,8 +45,8 @@ export const GET = deprecatedSmartRouteHandler(async (req: NextRequest, options:
const providerId = options.params.provider;
const infoId = cookies().get("stack-oauth-" + state.slice(0, 8));
cookies().delete("stack-oauth-" + state.slice(0, 8));
const infoId = cookies().get("stack-oauth-inner-" + state.slice(0, 8));
cookies().delete("stack-oauth-inner-" + state.slice(0, 8));
if (!infoId) {
throw new StatusError(StatusError.BadRequest, "stack-oauth cookie not found");

View File

@ -2,8 +2,6 @@ import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { it, localRedirectUrl } from "../../../../../../helpers";
import { backendContext, niceBackendFetch } from "../../../../../backend-helpers";
// TODO: We need to mock STACK_GITHUB_CLIENT_ID and STACK_GITHUB_CLIENT_SECRET before we can run these tests, so they're currently marked as todo
function getAuthorizeQuery() {
const projectKeys = backendContext.value.projectKeys;
if (projectKeys === "no-project") throw new Error("No project keys found in the backend context");
@ -21,20 +19,20 @@ function getAuthorizeQuery() {
};
}
it.todo("should redirect the user to the OAuth provider with the right arguments", async ({ expect }) => {
const response = await niceBackendFetch("/api/v1/auth/oauth/authorize/github", {
it("should redirect the user to the OAuth provider with the right arguments", async ({ expect }) => {
const response = await niceBackendFetch("/api/v1/auth/oauth/authorize/facebook", {
redirect: "manual",
query: {
...getAuthorizeQuery(),
},
});
expect(response.status).toBe(307);
expect(response.headers.get("location")).toMatch(/^https:\/\/github\.com\/login\/oauth\/authorize\?.*$/);
expect(response.headers.get("set-cookie")).toMatch(/^stack-oauth-[^;]+=[^;]+; Path=\/; Expires=[^;]+; Max-Age=\d+; Secure; HttpOnly$/);
expect(response.headers.get("location")).toMatch(/^http:\/\/localhost:8107\/auth\?.*$/);
expect(response.headers.get("set-cookie")).toMatch(/^stack-oauth-inner-[^;]+=[^;]+; Path=\/; Expires=[^;]+; Max-Age=\d+; (Secure;)? HttpOnly$/);
});
it.todo("should fail if an invalid client_id is provided", async ({ expect }) => {
const response = await niceBackendFetch("/api/v1/auth/oauth/authorize/github", {
it("should fail if an invalid client_id is provided", async ({ expect }) => {
const response = await niceBackendFetch("/api/v1/auth/oauth/authorize/facebook", {
redirect: "manual",
query: {
...getAuthorizeQuery(),
@ -57,8 +55,8 @@ it.todo("should fail if an invalid client_id is provided", async ({ expect }) =>
`);
});
it.todo("should fail if an invalid client_secret is provided", async ({ expect }) => {
const response = await niceBackendFetch("/api/v1/auth/oauth/authorize/github", {
it("should fail if an invalid client_secret is provided", async ({ expect }) => {
const response = await niceBackendFetch("/api/v1/auth/oauth/authorize/facebook", {
redirect: "manual",
query: {
...getAuthorizeQuery(),

View File

@ -0,0 +1,18 @@
{
"name": "@stackframe/oauth-mock-server",
"version": "1.0.0",
"private": true,
"main": "index.js",
"scripts": {
"start": "tsx src/index.ts",
"dev": "tsx watch --clear-screen=false src/index.ts"
},
"dependencies": {
"@types/oidc-provider": "^8.5.1",
"@stackframe/stack-shared": "workspace:*",
"oidc-provider": "^8.5.1"
},
"devDependencies": {
"tsx": "^4.16.2"
}
}

View File

@ -0,0 +1,27 @@
import Provider, { Configuration } from 'oidc-provider';
const port = Number.parseInt(process.env.PORT || "8107");
const mockedProviders = [
"github",
"facebook",
"google",
"microsoft",
"spotify",
];
const configuration: Configuration = {
clients: mockedProviders.map((providerId) => ({
client_id: providerId,
client_secret: 'MOCK-SERVER-SECRET',
redirect_uris: [
`http://localhost:8102/api/v1/auth/oauth/callback/${providerId}`,
],
})),
};
const oidc = new Provider(`http://localhost:${port}`, configuration);
oidc.listen(port, () => {
console.log(`oidc-provider listening on port ${port}, check http://localhost:${port}/.well-known/openid-configuration`);
});

View File

@ -0,0 +1,37 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"noErrorTruncation": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
},
"skipLibCheck": true
},
"include": [
"src/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@ -3,9 +3,9 @@
"version": "2.5.5",
"private": true,
"scripts": {
"dev": "next dev --port 8107",
"dev": "next dev --port 8112",
"build": "next build",
"start": "next start --port 8107",
"start": "next start --port 8112",
"lint": "next lint"
},
"dependencies": {

View File

@ -19,6 +19,7 @@
"start": "only-allow pnpm && turbo run start --concurrency 99999",
"start:backend": "only-allow pnpm && turbo run start --concurrency 99999 --filter=@stackframe/stack-backend",
"start:dashboard": "only-allow pnpm && turbo run start --concurrency 99999 --filter=@stackframe/stack-dashboard",
"start:oauth-mock-server": "only-allow pnpm && turbo run start --concurrency 99999 --filter=@stackframe/oauth-mock-server",
"lint": "only-allow pnpm && turbo run lint -- --max-warnings=0",
"release": "only-allow pnpm && release",
"peek": "only-allow pnpm && pnpm release --peek",

View File

@ -133,6 +133,7 @@ class RetryError extends AggregateError {
return this.errors.length;
}
}
RetryError.prototype.name = "RetryError";
async function retry<T>(
fn: () => Result<T> | Promise<Result<T>>,

File diff suppressed because it is too large Load Diff