Skip to content

Commit 84960ec

Browse files
authored
Mock OAuth server (stack-auth#138)
1 parent fd6f6c6 commit 84960ec

File tree

24 files changed

+884
-75
lines changed

24 files changed

+884
-75
lines changed

.github/workflows/e2e-api-tests.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,15 @@ jobs:
7878
tail: true
7979
wait-for: 30s
8080
log-output-if: true
81+
- name: Start oauth-mock-server in background
82+
uses: JarvusInnovations/background-action@v1.0.7
83+
with:
84+
run: pnpm run start:oauth-mock-server --log-order=stream &
85+
wait-on: |
86+
http://localhost:8102
87+
tail: true
88+
wait-for: 30s
89+
log-output-if: true
8190

8291
- name: Run tests
8392
run: pnpm test

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"nextjs",
2424
"Nicifiable",
2525
"nicify",
26+
"oidc",
2627
"openapi",
2728
"Proxied",
2829
"reqs",

apps/backend/.env

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
STACK_BASE_URL=# enter the URL of the backend here. For local development, use `http://localhost:8102`.
33
STACK_SERVER_SECRET=# enter a secret key generated by `pnpm generate-keys` here. This is used to sign the JWT tokens.
44

5+
# OAuth mock provider settings
6+
STACK_OAUTH_MOCK_URL=# enter the URL of the mock OAuth provider here. For local development, use `http://localhost:8107`.
7+
58
# OAuth shared keys
6-
# Can be omitted if shared OAuth keys are not needed
9+
# Can be set to MOCK to use mock OAuth providers
710
STACK_GITHUB_CLIENT_ID=# client
811
STACK_GITHUB_CLIENT_SECRET=# client secret
912
STACK_GOOGLE_CLIENT_ID=# client id

apps/backend/.env.development

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
11
STACK_BASE_URL=http://localhost:8102
22
STACK_SERVER_SECRET=23-wuNpik0gIW4mruTz25rbIvhuuvZFrLOLtL7J4tyo
33

4+
STACK_OAUTH_MOCK_URL=http://localhost:8107
5+
6+
STACK_GITHUB_CLIENT_ID=MOCK
7+
STACK_GITHUB_CLIENT_SECRET=MOCK
8+
STACK_GOOGLE_CLIENT_ID=MOCK
9+
STACK_GOOGLE_CLIENT_SECRET=MOCK
10+
STACK_FACEBOOK_CLIENT_ID=MOCK
11+
STACK_FACEBOOK_CLIENT_SECRET=MOCK
12+
STACK_MICROSOFT_CLIENT_ID=MOCK
13+
STACK_MICROSOFT_CLIENT_SECRET=MOCK
14+
STACK_SPOTIFY_CLIENT_ID=MOCK
15+
STACK_SPOTIFY_CLIENT_SECRET=MOCK
16+
417
STACK_DATABASE_CONNECTION_STRING=postgres://postgres:password@localhost:5432/stackframe
518
STACK_DIRECT_DATABASE_CONNECTION_STRING=postgres://postgres:password@localhost:5432/stackframe
619

apps/backend/src/app/api/v1/auth/oauth/authorize/[provider]/route.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ export const GET = createSmartRouteHandler({
8383

8484
const innerCodeVerifier = generators.codeVerifier();
8585
const innerState = generators.state();
86-
const oauthUrl = getProvider(provider).getAuthorizationUrl({
86+
const providerObj = await getProvider(provider);
87+
const oauthUrl = providerObj.getAuthorizationUrl({
8788
codeVerifier: innerCodeVerifier,
8889
state: innerState,
8990
extraScope: query.provider_scope,
@@ -115,7 +116,7 @@ export const GET = createSmartRouteHandler({
115116
// prevent CSRF by keeping track of the inner state in cookies
116117
// the callback route must ensure that the inner state cookie is set
117118
cookies().set(
118-
"stack-oauth-" + innerState,
119+
"stack-oauth-inner-" + innerState,
119120
"true",
120121
{
121122
httpOnly: true,

apps/backend/src/app/api/v1/auth/oauth/callback/[provider]/route.tsx

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ export const GET = createSmartRouteHandler({
3939
headers: yupMixed().required(),
4040
}),
4141
async handler({ params, query }, fullReq) {
42-
const cookieInfo = cookies().get("stack-oauth-" + query.state);
43-
cookies().delete("stack-oauth-" + query.state);
42+
const innerState = query.state ?? "";
43+
const cookieInfo = cookies().get("stack-oauth-inner-" + innerState);
44+
cookies().delete("stack-oauth-inner-" + query.state);
4445

4546
if (cookieInfo?.value !== 'true') {
4647
throw new StatusError(StatusError.BadRequest, "stack-oauth cookie not found");
@@ -88,13 +89,11 @@ export const GET = createSmartRouteHandler({
8889
throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled();
8990
}
9091

91-
const userInfo = await getProvider(provider).getCallback({
92+
const providerObj = await getProvider(provider);
93+
const userInfo = await providerObj.getCallback({
9294
codeVerifier: innerCodeVerifier,
93-
state: query.state ?? throwErr(new StatusError(StatusError.BadRequest, "Must provide state in query")),
94-
callbackParams: {
95-
code: query.code ?? throwErr(new StatusError(StatusError.BadRequest, "Must provide code in query")),
96-
state: query.state ?? throwErr(new StatusError(StatusError.BadRequest, "Must provide state in query")),
97-
}
95+
state: innerState,
96+
callbackParams: query,
9897
});
9998

10099
if (type === "link") {
@@ -152,7 +151,7 @@ export const GET = createSmartRouteHandler({
152151
oAuthProviderConfigId: provider.id,
153152
refreshToken: userInfo.refreshToken,
154153
providerAccountId: userInfo.accountId,
155-
scopes: extractScopes(getProvider(provider).scope + " " + providerScope),
154+
scopes: extractScopes(providerObj.scope + " " + providerScope),
156155
}
157156
});
158157
}

apps/backend/src/oauth/index.tsx

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import OAuth2Server from "@node-oauth/oauth2-server";
22
import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects";
33
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
4-
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
4+
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
55
import { OAuthModel } from "./model";
66
import { OAuthBaseProvider } from "./providers/base";
77
import { FacebookProvider } from "./providers/facebook";
88
import { GithubProvider } from "./providers/github";
99
import { GoogleProvider } from "./providers/google";
1010
import { MicrosoftProvider } from "./providers/microsoft";
1111
import { SpotifyProvider } from "./providers/spotify";
12+
import { MockProvider } from "./providers/mock";
1213

1314
const _providers = {
1415
github: GithubProvider,
@@ -18,21 +19,32 @@ const _providers = {
1819
spotify: SpotifyProvider,
1920
} as const;
2021

22+
const mockProvider = MockProvider;
23+
2124
const _getEnvForProvider = (provider: keyof typeof _providers) => {
2225
return {
2326
clientId: getEnvVariable(`STACK_${provider.toUpperCase()}_CLIENT_ID`),
2427
clientSecret: getEnvVariable(`STACK_${provider.toUpperCase()}_CLIENT_SECRET`),
2528
};
2629
};
2730

28-
export function getProvider(provider: ProjectsCrud['Admin']['Read']['config']['oauth_providers'][number]): OAuthBaseProvider {
31+
export async function getProvider(provider: ProjectsCrud['Admin']['Read']['config']['oauth_providers'][number]): Promise<OAuthBaseProvider> {
2932
if (provider.type === 'shared') {
30-
return new _providers[provider.id]({
31-
clientId: _getEnvForProvider(provider.id).clientId,
32-
clientSecret: _getEnvForProvider(provider.id).clientSecret,
33-
});
33+
const clientId = _getEnvForProvider(provider.id).clientId;
34+
const clientSecret = _getEnvForProvider(provider.id).clientSecret;
35+
if (clientId === "MOCK") {
36+
if (clientSecret !== "MOCK") {
37+
throw new StackAssertionError("If OAuth provider client ID is set to MOCK, then client secret must also be set to MOCK");
38+
}
39+
return await mockProvider.create(provider.id);
40+
} else {
41+
return await _providers[provider.id].create({
42+
clientId,
43+
clientSecret,
44+
});
45+
}
3446
} else {
35-
return new _providers[provider.id]({
47+
return await _providers[provider.id].create({
3648
clientId: provider.client_id || throwErr("Client ID is required for standard providers"),
3749
clientSecret: provider.client_secret || throwErr("Client secret is required for standard providers"),
3850
});

apps/backend/src/oauth/providers/base.tsx

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,50 +4,60 @@ import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"
44
import { mergeScopeStrings } from "@stackframe/stack-shared/dist/utils/strings";
55

66
export abstract class OAuthBaseProvider {
7-
issuer: Issuer;
8-
scope: string;
9-
oauthClient: Client;
10-
redirectUri: string;
7+
constructor(
8+
public readonly oauthClient: Client,
9+
public readonly scope: string,
10+
public readonly redirectUri: string,
11+
) {}
1112

12-
constructor(options: {
13-
issuer: string,
14-
authorizationEndpoint: string,
15-
tokenEndpoint: string,
16-
userinfoEndpoint?: string,
17-
clientId: string,
18-
clientSecret: string,
19-
redirectUri: string,
20-
baseScope: string,
21-
}) {
22-
this.issuer = new Issuer({
13+
protected static async createConstructorArgs(options:
14+
& {
15+
clientId: string,
16+
clientSecret: string,
17+
redirectUri: string,
18+
baseScope: string,
19+
isMock?: boolean,
20+
}
21+
& (
22+
| {
23+
issuer: string,
24+
authorizationEndpoint: string,
25+
tokenEndpoint: string,
26+
userinfoEndpoint?: string,
27+
}
28+
| {
29+
discoverFromUrl: string,
30+
}
31+
)
32+
) {
33+
const issuer = "discoverFromUrl" in options ? await Issuer.discover(options.discoverFromUrl) : new Issuer({
2334
issuer: options.issuer,
2435
authorization_endpoint: options.authorizationEndpoint,
2536
token_endpoint: options.tokenEndpoint,
2637
userinfo_endpoint: options.userinfoEndpoint,
2738
});
28-
this.oauthClient = new this.issuer.Client({
39+
const oauthClient = new issuer.Client({
2940
client_id: options.clientId,
3041
client_secret: options.clientSecret,
3142
redirect_uri: options.redirectUri,
3243
response_types: ["code"],
3344
});
3445

3546
// facebook always return an id_token even in the OAuth2 flow, which is not supported by openid-client
36-
const oldGrant = this.oauthClient.grant;
47+
const oldGrant = oauthClient.grant;
3748
if (!(oldGrant as any)) {
3849
// it seems that on Sentry, this was undefined in one scenario, so let's log some data to help debug if it happens again
3950
// not sure if that is actually what was going on? the error log has very few details
4051
// https://stackframe-pw.sentry.io/issues/5515577938
41-
throw new StackAssertionError("oldGrant is undefined for some reason — that should never happen!", { options, oauthClient: this.oauthClient });
52+
throw new StackAssertionError("oldGrant is undefined for some reason — that should never happen!", { options, oauthClient });
4253
}
43-
this.oauthClient.grant = async function (params) {
54+
oauthClient.grant = async function (params) {
4455
const grant = await oldGrant.call(this, params);
4556
delete grant.id_token;
4657
return grant;
4758
};
4859

49-
this.redirectUri = options.redirectUri;
50-
this.scope = options.baseScope;
60+
return [oauthClient, options.baseScope, options.redirectUri] as const;
5161
}
5262

5363
getAuthorizationUrl(options: {
@@ -71,14 +81,14 @@ export abstract class OAuthBaseProvider {
7181
state: string,
7282
}): Promise<OAuthUserInfo> {
7383
let tokenSet;
84+
const params = {
85+
code_verifier: options.codeVerifier,
86+
state: options.state,
87+
};
7488
try {
75-
const params = {
76-
code_verifier: options.codeVerifier,
77-
state: options.state,
78-
};
7989
tokenSet = await this.oauthClient.oauthCallback(this.redirectUri, options.callbackParams, params);
8090
} catch (error) {
81-
throw new StackAssertionError("OAuth callback failed", undefined, { cause: error });
91+
throw new StackAssertionError(`Inner OAuth callback failed due to error: ${error}`, undefined, { cause: error });
8292
}
8393
if (!tokenSet.access_token) {
8494
throw new StackAssertionError("No access token received", { tokenSet });

apps/backend/src/oauth/providers/facebook.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,24 @@ import { OAuthUserInfo, validateUserInfo } from "../utils";
44
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
55

66
export class FacebookProvider extends OAuthBaseProvider {
7-
constructor(options: {
7+
private constructor(
8+
...args: ConstructorParameters<typeof OAuthBaseProvider>
9+
) {
10+
super(...args);
11+
}
12+
13+
static async create(options: {
814
clientId: string,
915
clientSecret: string,
1016
}) {
11-
super({
17+
return new FacebookProvider(...await OAuthBaseProvider.createConstructorArgs({
1218
issuer: "https://www.facebook.com",
1319
authorizationEndpoint: "https://facebook.com/v20.0/dialog/oauth/",
1420
tokenEndpoint: "https://graph.facebook.com/v20.0/oauth/access_token",
1521
redirectUri: getEnvVariable("STACK_BASE_URL") + "/api/v1/auth/oauth/callback/facebook",
1622
baseScope: "public_profile email",
1723
...options
18-
});
24+
}));
1925
}
2026

2127
async postProcessUserInfo(tokenSet: TokenSet): Promise<OAuthUserInfo> {

apps/backend/src/oauth/providers/github.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,25 @@ import { OAuthUserInfo, validateUserInfo } from "../utils";
44
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
55

66
export class GithubProvider extends OAuthBaseProvider {
7-
constructor(options: {
7+
private constructor(
8+
...args: ConstructorParameters<typeof OAuthBaseProvider>
9+
) {
10+
super(...args);
11+
}
12+
13+
static async create(options: {
814
clientId: string,
915
clientSecret: string,
1016
}) {
11-
super({
17+
return new GithubProvider(...await OAuthBaseProvider.createConstructorArgs({
1218
issuer: "https://github.com",
1319
authorizationEndpoint: "https://github.com/login/oauth/authorize",
1420
tokenEndpoint: "https://github.com/login/oauth/access_token",
1521
userinfoEndpoint: "https://api.github.com/user",
1622
redirectUri: getEnvVariable("STACK_BASE_URL") + "/api/v1/auth/oauth/callback/github",
1723
baseScope: "user:email",
1824
...options,
19-
});
25+
}));
2026
}
2127

2228
async postProcessUserInfo(tokenSet: TokenSet): Promise<OAuthUserInfo> {

0 commit comments

Comments
 (0)