Skip to content

Commit

Permalink
feat!: add OAuth2 support (#834)
Browse files Browse the repository at this point in the history
* feat: add boilerplate for OAuth2 recipe

* feat!: add initial impl for OAuth2 (#833)

* feat: add initial impl of OAuth2 recipe

* build: add missing bundle conf

* fix: wrong recipe id

* feat: clean up todos

* feat: make use of getLoginChallengeInfo

* fix: self-review fixes

* refactor: rename OAuth2 to OAuth2Provider

* feat: show logo for oauth clients

* fix: Rename oauth2 to oauth2provider

* test: Add e2e test for OAuth2 (#843)

* test: Add e2e test for OAuth2

* fix: PR changes

* feat: add tryLinkingWithSessionUser, forceFreshAuth and small test fixes

* test: add explanation comment to oauth2 tests

---------

Co-authored-by: Mihaly Lengyel <[email protected]>

* feat: add a route we can use to force refreshes

* test: extend/stabilize tests

* feat: Add functions and prebuiltUI for oauth2 logout (#850)

* feat: Add functions and prebuiltUI for oauth2 logout

* Update lib/ts/recipe/oauth2provider/components/themes/themeBase.tsx

Co-authored-by: Mihály Lengyel <[email protected]>

* fix: PR changes

* fix: PR changes

---------

Co-authored-by: Mihály Lengyel <[email protected]>

* Add OAuth2 example apps  (#854)

* feat: Add st-oauth2-authorization-server example

* feat: Add with-oauth2-without-supertokens

* feat: Add with-oauth2-with-supertokens example

* feat: keep the tenantId queryparam during redirections

* feat: update to match node changes

* test: stability fixes

* test: update dep version and fix tests

* fix: ignore appname in the oauth flow if it is empty

* fix: fix typo

* feat: handle not initialized OAuth2Provider recipe more gracefully

* feat: ignore loginChallenge queryparam on auth page if we couldn't load it

* feat: show an error if the getLoginChallengeInfo errors out

* feat: update prebuiltui types and add test into with-typescript

* test: add more debugging options for ci

* fix: shouldTryLinkingWithSessionUser

* chore: update versions

* ci: do not forward browser logs into the console on CI

* test: improve request logging in tests

* test: update test expectations to match new node logic

* chore: update web-js dep version in lock

---------

Co-authored-by: Mihaly Lengyel <[email protected]>

* refactor: self-review fixes

* refactor: self-review fixes

* docs: remove oauth2 examples until the restructuring is done

* chore: expand changelog

* chore: set web-js version to new version branch

* chore: update size limits

---------

Co-authored-by: Ankit Tiwari <[email protected]>
  • Loading branch information
porcellus and anku255 authored Oct 3, 2024
1 parent 7501c03 commit 89d421c
Show file tree
Hide file tree
Showing 207 changed files with 5,435 additions and 843 deletions.
85 changes: 85 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,91 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [unreleased]

## [0.49.0] - 2024-10-07

### Changes

- Added the `OAuth2Provider` recipe
- Changed the input types and default implementation of `AuthPageHeader` to show the client information in OAuth2 flows

### Breaking changes

- Now only supporting FDI 4.0 (Node >= 24.0.0)
- All `getRedirectionURL` functions now also get a new `tenantIdFromQueryParams` prop
- This is used in OAuth2 + Multi-tenant flows.
- This should be safe to ignore if:
- You are not using those recipes
- You have a custom `getTenantId` implementation
- You are not customizing paths of the pages handled by SuperTokens.
- This is used to keep the `tenantId` query param during internal redirections between pages handled by the SDK.
- If you have custom paths, you should set the tenantId queryparam based on this. (See migrations below for more details)
- Added a new `shouldTryLinkingToSessionUser` flag to sign in/up related function inputs:
- No action is needed if you are not using MFA/session based account linking.
- If you are implementing MFA:
- Plase set this flag to `false` (or leave as undefined) during first factor sign-ins
- Please set this flag to `true` for secondary factors.
- Please forward this flag to the original implementation in any of your overrides.
- Changed functions:
- `EmailPassword`:
- `signIn`, `signUp`: both override and callable functions
- `ThirdParty`:
- `getAuthorisationURLWithQueryParamsAndSetState`: both override and callable function
- `redirectToThirdPartyLogin`: callable function takes this flag as an optional input (it defaults to false on the backend)
- `Passwordless`:
- Functions overrides: `consumeCode`, `resendCode`, `createCode`, `setLoginAttemptInfo`, `getLoginAttemptInfo`
- Calling `createCode` and `setLoginAttemptInfo` take this flag as an optional input (it defaults to false on the backend)
- Changed the default implementation of `getTenantId` to default to the `tenantId` query parameter (if present) then falling back to the public tenant instead of always defaulting to the public tenant
- We now disable session based account linking in the magic link based flow in passwordless by default
- This is to make it function more consistently instead of only working if the link was opened on the same device
- You can override by overriding the `consumeCode` function in the Passwordless Recipe

### Migration

#### tenantIdFromQueryParams in getRedirectionURL

Before:

```ts
EmailPassword.init({
async getRedirectionURL(context) {
if (context.action === "RESET_PASSWORD") {
return `/reset-password`;
}
return "";
},
});
```

After:

```ts
EmailPassword.init({
async getRedirectionURL(context) {
return `/reset-password?tenantId=${context.tenantIdFromQueryParams}`;
},
});
```

#### Session based account linking for magic link based flows

You can re-enable linking by overriding the `consumeCode` function in the passwordless recipe and setting `shouldTryLinkingToSessionUser` to `true`.

```ts
Passwordless.init({
override: {
functions: (original) => {
return {
...original,
consumeCode: async (input) => {
// Please note that this is means that the session is required and will cause an error if it is not present
return original.consumeCode({ ...input, shouldTryLinkingWithSessionUser: true });
},
};
},
},
});
```

## [0.47.1] - 2024-09-18

### Fixes
Expand Down
2 changes: 2 additions & 0 deletions examples/for-tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
"private": true,
"dependencies": {
"axios": "^0.21.0",
"oidc-client-ts": "^3.0.1",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-oidc-context": "^3.1.0",
"react-router-dom": "6.11.2",
"react-scripts": "^5.0.1"
},
Expand Down
17 changes: 4 additions & 13 deletions examples/for-tests/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import Multitenancy from "supertokens-auth-react/recipe/multitenancy";
import UserRoles from "supertokens-auth-react/recipe/userroles";
import MultiFactorAuth from "supertokens-auth-react/recipe/multifactorauth";
import TOTP from "supertokens-auth-react/recipe/totp";
import OAuth2Provider from "supertokens-auth-react/recipe/oauth2provider";

import axios from "axios";
import { useSessionContext } from "supertokens-auth-react/recipe/session";
Expand All @@ -27,6 +28,7 @@ import { logWithPrefix } from "./logWithPrefix";
import { ErrorBoundary } from "./ErrorBoundary";
import { useNavigate } from "react-router-dom";
import { getTestContext, getEnabledRecipes, getQueryParams } from "./testContext";
import { getApiDomain, getWebsiteDomain } from "./config";

const loadv5RRD = window.localStorage.getItem("react-router-dom-is-v5") === "true";
if (loadv5RRD) {
Expand All @@ -43,18 +45,6 @@ const withRouter = function (Child) {

Session.addAxiosInterceptors(axios);

export function getApiDomain() {
const apiPort = process.env.REACT_APP_API_PORT || 8082;
const apiUrl = process.env.REACT_APP_API_URL || `http://localhost:${apiPort}`;
return apiUrl;
}

export function getWebsiteDomain() {
const websitePort = process.env.REACT_APP_WEBSITE_PORT || 3031;
const websiteUrl = process.env.REACT_APP_WEBSITE_URL || `http://localhost:${websitePort}`;
return getQueryParams("websiteDomain") ?? websiteUrl;
}

/*
* Use localStorage for tests configurations.
*/
Expand Down Expand Up @@ -419,6 +409,7 @@ let recipeList = [
console.log(`ST_LOGS SESSION ON_HANDLE_EVENT ${ctx.action}`);
},
}),
OAuth2Provider.init(),
];

let enabledRecipes = getEnabledRecipes();
Expand Down Expand Up @@ -452,6 +443,7 @@ if (testContext.enableMFA) {
SuperTokens.init({
usesDynamicLoginMethods: testContext.usesDynamicLoginMethods,
clientType: testContext.clientType,
enableDebugLogs: true,
appInfo: {
appName: "SuperTokens",
websiteDomain: getWebsiteDomain(),
Expand Down Expand Up @@ -813,7 +805,6 @@ function getSignInFormFields(formType) {
id: "test",
},
];
return;
}
}

Expand Down
7 changes: 6 additions & 1 deletion examples/for-tests/src/AppWithReactDomRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import { EmailPasswordPreBuiltUI } from "supertokens-auth-react/recipe/emailpass
import { PasswordlessPreBuiltUI } from "supertokens-auth-react/recipe/passwordless/prebuiltui";
import { EmailVerificationPreBuiltUI } from "supertokens-auth-react/recipe/emailverification/prebuiltui";
import { ThirdPartyPreBuiltUI, SignInAndUpCallback } from "supertokens-auth-react/recipe/thirdparty/prebuiltui";
import { OAuth2ProviderPreBuiltUI } from "supertokens-auth-react/recipe/oauth2provider/prebuiltui";
import { AccessDeniedScreen } from "supertokens-auth-react/recipe/session/prebuiltui";
import { MultiFactorAuthPreBuiltUI } from "supertokens-auth-react/recipe/multifactorauth/prebuiltui";
import { TOTPPreBuiltUI } from "supertokens-auth-react/recipe/totp/prebuiltui";
import { BaseComponent, Home, Contact, Dashboard, DashboardNoAuthRequired } from "./App";
import { getEnabledRecipes, getTestContext } from "./testContext";
import OAuth2Page from "./OAuth2Page";

function AppWithReactDomRouter(props) {
/**
Expand All @@ -30,7 +32,7 @@ function AppWithReactDomRouter(props) {
const emailVerificationMode = window.localStorage.getItem("mode") || "OFF";
const websiteBasePath = window.localStorage.getItem("websiteBasePath") || undefined;

let recipePreBuiltUIList = [TOTPPreBuiltUI];
let recipePreBuiltUIList = [TOTPPreBuiltUI, OAuth2ProviderPreBuiltUI];
if (enabledRecipes.some((r) => r.startsWith("thirdparty"))) {
recipePreBuiltUIList.push(ThirdPartyPreBuiltUI);
}
Expand Down Expand Up @@ -172,6 +174,9 @@ function AppWithReactDomRouter(props) {
}
/>
)}

<Route path="/oauth/login" element={<OAuth2Page />} />
<Route path="/oauth/callback" element={<OAuth2Page />} />
</Routes>
</BaseComponent>
</Router>
Expand Down
91 changes: 91 additions & 0 deletions examples/for-tests/src/OAuth2Page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { AuthProvider, useAuth } from "react-oidc-context";
import { getApiDomain, getWebsiteDomain } from "./config";

// NOTE: For convenience, the same page/component handles both login initiation and callback.
// Separate pages for login and callback are not required.

const scopes = window.localStorage.getItem("oauth2-scopes") ?? "profile openid offline_access email";
const extraConfig = JSON.parse(window.localStorage.getItem("oauth2-extra-config") ?? "{}");
const extraSignInParams = JSON.parse(window.localStorage.getItem("oauth2-extra-sign-in-params") ?? "{}");
const extraSignOutParams = JSON.parse(window.localStorage.getItem("oauth2-extra-sign-out-params") ?? "{}");

const oidcConfig = {
client_id: window.localStorage.getItem("oauth2-client-id"),
authority: `${getApiDomain()}/auth`,
response_type: "code",
redirect_uri: `${getWebsiteDomain()}/oauth/callback`,
scope: scopes ? scopes : "profile openid offline_access email",
...extraConfig,
onSigninCallback: async (user) => {
// Clears the response code and other params from the callback url
window.history.replaceState({}, document.title, window.location.pathname);
},
};

function AuthPage() {
const { signinRedirect, signinSilent, signoutSilent, signoutRedirect, user, error } = useAuth();

return (
<div>
<h1 style={{ textAlign: "center" }}>OAuth2 Login Test</h1>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center" }}>
{error && <p id="oauth2-error-message">Error: {error.message}</p>}
{user && (
<>
<pre id="oauth2-token-data">{JSON.stringify(user.profile, null, 2)}</pre>
<button id="oauth2-logout-button" onClick={() => signoutSilent(extraSignOutParams)}>
Logout
</button>
<button id="oauth2-logout-button-redirect" onClick={() => signoutRedirect(extraSignOutParams)}>
Logout (Redirect)
</button>
</>
)}
<button id="oauth2-login-button" onClick={() => signinRedirect(extraSignInParams)}>
Login With SuperTokens
</button>
<button id="oauth2-login-button-silent" onClick={() => signinSilent(extraSignInParams)}>
Login With SuperTokens (silent)
</button>
<button
id="oauth2-login-button-prompt-login"
onClick={() =>
signinRedirect({
prompt: "login",
...extraSignInParams,
})
}>
Login With SuperTokens (prompt=login)
</button>
<button
id="oauth2-login-button-max-age-3"
onClick={() =>
signinRedirect({
max_age: 3,
...extraSignInParams,
})
}>
Login With SuperTokens (max_age=3)
</button>
<button
id="oauth2-login-button-prompt-none"
onClick={() =>
signinRedirect({
prompt: "none",
...extraSignInParams,
})
}>
Login With SuperTokens (prompt=none)
</button>
</div>
</div>
);
}

export default function OAuth2Page() {
return (
<AuthProvider {...oidcConfig}>
<AuthPage />
</AuthProvider>
);
}
13 changes: 13 additions & 0 deletions examples/for-tests/src/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { getQueryParams } from "./testContext";

export function getApiDomain() {
const apiPort = process.env.REACT_APP_API_PORT || 8082;
const apiUrl = process.env.REACT_APP_API_URL || `http://localhost:${apiPort}`;
return apiUrl;
}

export function getWebsiteDomain() {
const websitePort = process.env.REACT_APP_WEBSITE_PORT || 3031;
const websiteUrl = process.env.REACT_APP_WEBSITE_URL || `http://localhost:${websitePort}`;
return getQueryParams("websiteDomain") ?? websiteUrl;
}
2 changes: 1 addition & 1 deletion frontendDriverInterfaceSupported.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"_comment": "contains a list of frontend-backend interface versions that this package supports",
"versions": ["2.0", "3.0"]
"versions": ["4.0"]
}
2 changes: 1 addition & 1 deletion hooks/pre-commit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ else
fi

npm run check-circular-dependencies
circDep=$?
circDep=$?

echo "$(tput setaf 3)* No circular dependencies?$(tput sgr 0)"

Expand Down
5 changes: 4 additions & 1 deletion lib/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ module.exports = {
],
"@typescript-eslint/naming-convention": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [2, { vars: "all", args: "all", varsIgnorePattern: "^React$|^jsx$" }],
"@typescript-eslint/no-unused-vars": [
2,
{ vars: "all", args: "all", varsIgnorePattern: "^React$|^jsx$", argsIgnorePattern: "^_" },
],
"@typescript-eslint/prefer-namespace-keyword": "error",
"@typescript-eslint/quotes": ["error", "double"],
"@typescript-eslint/semi": ["error", "always"],
Expand Down
2 changes: 2 additions & 0 deletions lib/build/components/assets/logoutIcon.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions lib/build/constants.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 5 additions & 15 deletions lib/build/emailpassword-shared3.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions lib/build/emailpassword.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 89d421c

Please sign in to comment.