diff --git a/admin-panel/app/Setup.tsx b/admin-panel/app/Setup.tsx index 098d54be..7d071797 100644 --- a/admin-panel/app/Setup.tsx +++ b/admin-panel/app/Setup.tsx @@ -23,7 +23,7 @@ const AuthSetup = ({ children }: PropsWithChildren) => { const { isAuthenticating, isAuthenticated, acquireUserInfo, - loginRedirect, logoutRedirect, isLoading, + loginRedirect, logoutRedirect, isLoadingUserInfo, acquireUserInfoError, } = useAuth() const userInfo = useSignalValue(userInfoSignal) @@ -44,7 +44,7 @@ const AuthSetup = ({ children }: PropsWithChildren) => { [acquireUserInfo, isAuthenticated], ) - if (isAuthenticating || isLoading) { + if (isAuthenticating || isLoadingUserInfo) { return (
@@ -61,7 +61,7 @@ const AuthSetup = ({ children }: PropsWithChildren) => { return (
- {t('layout.blocked')} + {acquireUserInfoError || t('layout.blocked')} + } +} +``` + ## acquireToken Gets the user's accessToken, or refreshes it if expired. @@ -132,39 +135,106 @@ export default function Home () { } ``` -## isLoading +## isAuthenticating -Indicates whether the SDK is fetching user info. +Indicates whether the SDK is initializing and attempting to obtain the user's authentication state. ``` import { useAuth } from '@melody-auth/react' export default function Home () { - const { isLoading } = useAuth() + const { isAuthenticating } = useAuth() - if (isLoading) return + if (isAuthenticating) return } ``` -## logoutRedirect +## isLoadingToken -Triggers the logout flow. +Indicates whether the SDK is acquiring token. +``` +import { useAuth } from '@melody-auth/react' -| Parameter | Type | Description | Default | Required | -|-----------|------|-------------|---------|----------| -| postLogoutRedirectUri | string | The URL to redirect users after logout | N/A | No | +export default function Home () { + const { isLoadingToken } = useAuth() + if (isLoadingToken) return +} +``` + +## isLoadingUserInfo + +Indicates whether the SDK is acquiring user info. ``` import { useAuth } from '@melody-auth/react' export default function Home () { - const { isAuthenticated, logoutRedirect } = useAuth() + const { isLoadingUserInfo } = useAuth() - const handleLogout = () => { - logoutRedirect({ postLogoutRedirectUri: 'http://localhost:3000/' }) - } + if (isLoadingUserInfo) return +} +``` - if (isAuthenticated) { - return - } +## authenticationError + +Indicates whether there is an authentication process related error. +``` +import { useAuth } from '@melody-auth/react' + +export default function Home () { + const { authenticationError } = useAuth() + + if (authenticationError) return +} +``` + +## acquireTokenError + +Indicates whether there is an acquireToken process related error. +``` +import { useAuth } from '@melody-auth/react' + +export default function Home () { + const { acquireTokenError } = useAuth() + + if (acquireTokenError) return +} +``` + +## acquireUserInfoError + +Indicates whether there is an acquireUserInfo process related error. +``` +import { useAuth } from '@melody-auth/react' + +export default function Home () { + const { acquireUserInfoError } = useAuth() + + if (acquireUserInfoError) return +} +``` + +## loginError + +Indicates whether there is an login process related error. +``` +import { useAuth } from '@melody-auth/react' + +export default function Home () { + const { loginError } = useAuth() + + if (loginError) return +} +``` + +## logoutError + +Indicates whether there is an login process related error. +``` +import { useAuth } from '@melody-auth/react' + +export default function Home () { + const { logoutError } = useAuth() + + if (logoutError) return } ``` diff --git a/react-sdk/.npmignore b/react-sdk/.npmignore new file mode 100644 index 00000000..bf40d278 --- /dev/null +++ b/react-sdk/.npmignore @@ -0,0 +1,2 @@ +node_modules +src \ No newline at end of file diff --git a/react-sdk/README.md b/react-sdk/README.md new file mode 100644 index 00000000..4ea665fb --- /dev/null +++ b/react-sdk/README.md @@ -0,0 +1,9 @@ +# React SDK + +Melody Auth React SDK facilitates seamless interaction between React applications and the melody auth server. It silently handles authentication state management, redirect flows, token exchange, and authentication validation for you. + +## Installation + +``` +npm install @melody-auth/react --save +``` diff --git a/react-sdk/package.json b/react-sdk/package.json index 58535862..4d1166b2 100644 --- a/react-sdk/package.json +++ b/react-sdk/package.json @@ -1,5 +1,6 @@ { "name": "@melody-auth/react", + "version": "0.0.1", "main": "dist/index.js", "dependencies": { "web-sdk": "*" @@ -12,6 +13,7 @@ }, "scripts": { "build": "rm -rf ./dist && tsc --build", - "type:check": "tsc --noEmit" + "type:check": "tsc --noEmit", + "release": "rm -rf ./dist && npm run compile && npm publish" } } diff --git a/react-sdk/src/Provider.tsx b/react-sdk/src/Provider.tsx index 4d7b354a..bed1cc5b 100644 --- a/react-sdk/src/Provider.tsx +++ b/react-sdk/src/Provider.tsx @@ -23,6 +23,8 @@ const reducer = ( accessTokenStorage: action.payload, isAuthenticated: true, isAuthenticating: false, + isLoadingToken: false, + acquireTokenError: '', } case 'setRefreshTokenStorage': return { @@ -30,23 +32,58 @@ const reducer = ( refreshTokenStorage: action.payload, checkedStorage: true, } + case 'setIsAuthenticating': + return { + ...state, isAuthenticating: action.payload, + } + case 'setAuthenticationError': + return { + ...state, + authenticationError: action.payload, + isAuthenticating: false, + } + case 'setCheckedStorage': + return { + ...state, checkedStorage: action.payload, + } + case 'setIsLoadingUserInfo': + return { + ...state, isLoadingUserInfo: action.payload, + } + case 'setAcquireUserInfoError': + return { + ...state, + acquireUserInfoError: action.payload, + isLoadingUserInfo: false, + } case 'setUserInfo': return { ...state, userInfo: action.payload, - isLoading: false, + isLoadingUserInfo: false, + acquireUserInfoError: '', } - case 'setIsAuthenticating': + case 'setAcquireTokenError': return { - ...state, isAuthenticating: action.payload, + ...state, + acquireTokenError: action.payload, + isLoadingToken: false, + isAuthenticating: false, } - case 'setCheckedStorage': + case 'setIsLoadingToken': return { - ...state, checkedStorage: action.payload, + ...state, + isLoadingToken: true, } - case 'setIsLoading': + case 'setLoginError': return { - ...state, isLoading: action.payload, + ...state, + loginError: action.payload, + } + case 'setLogoutError': + return { + ...state, + logoutError: action.payload, } } } @@ -59,13 +96,19 @@ export const AuthProvider = ({ reducer, { isAuthenticating: true, + authenticationError: '', isAuthenticated: false, - isLoading: false, config, userInfo: null, accessTokenStorage: null, refreshTokenStorage: null, checkedStorage: false, + isLoadingUserInfo: false, + acquireUserInfoError: '', + isLoadingToken: false, + acquireTokenError: '', + loginError: '', + logoutError: '', }, ) diff --git a/react-sdk/src/Setup.ts b/react-sdk/src/Setup.ts index 8109fe09..f9175076 100644 --- a/react-sdk/src/Setup.ts +++ b/react-sdk/src/Setup.ts @@ -5,6 +5,9 @@ import { import { exchangeTokenByAuthCode } from 'web-sdk' import { useAuth } from './useAuth' import authContext, { AuthContext } from './context' +import { + ErrorType, handleError, +} from './utils' const Setup = () => { const { acquireToken } = useAuth() @@ -24,9 +27,7 @@ const Setup = () => { if (state.accessTokenStorage) return if (state.refreshTokenStorage && !state.accessTokenStorage) { - acquireToken().catch(() => dispatch({ - type: 'setIsAuthenticating', payload: false, - })) + acquireToken() return } @@ -48,9 +49,13 @@ const Setup = () => { }) } }) - .catch(() => { + .catch((e) => { + const msg = handleError( + e, + ErrorType.ObtainAccessToken, + ) dispatch({ - type: 'setIsAuthenticating', payload: false, + type: 'setAuthenticationError', payload: msg, }) }) } diff --git a/react-sdk/src/context.ts b/react-sdk/src/context.ts index aa41db7f..ea09f5d4 100644 --- a/react-sdk/src/context.ts +++ b/react-sdk/src/context.ts @@ -9,12 +9,18 @@ import { export interface AuthState { config: ProviderConfig; refreshTokenStorage: RefreshTokenStorage | null; - accessTokenStorage: AccessTokenStorage | null; - userInfo: GetUserInfoRes | null; isAuthenticated: boolean; isAuthenticating: boolean; + authenticationError: string; checkedStorage: boolean; - isLoading: boolean; + userInfo: GetUserInfoRes | null; + isLoadingUserInfo: boolean; + acquireUserInfoError: string; + accessTokenStorage: AccessTokenStorage | null; + isLoadingToken: boolean; + acquireTokenError: string; + loginError: string; + logoutError: string; } export type DispatchAction = @@ -23,7 +29,13 @@ export type DispatchAction = | { type: 'setUserInfo'; payload: GetUserInfoRes | null } | { type: 'setIsAuthenticating'; payload: boolean } | { type: 'setCheckedStorage'; payload: boolean } - | { type: 'setIsLoading'; payload: boolean } + | { type: 'setIsLoadingUserInfo'; payload: boolean } + | { type: 'setAcquireUserInfoError'; payload: string } + | { type: 'setIsLoadingToken'; payload: boolean } + | { type: 'setAcquireTokenError'; payload: string } + | { type: 'setAuthenticationError'; payload: string } + | { type: 'setLoginError'; payload: string } + | { type: 'setLogoutError'; payload: string } export type AuthDispatch = Dispatch diff --git a/react-sdk/src/index.tsx b/react-sdk/src/index.tsx index 02ad95bc..58813afd 100644 --- a/react-sdk/src/index.tsx +++ b/react-sdk/src/index.tsx @@ -1,10 +1,12 @@ import { GetUserInfoRes } from 'shared' import { AuthProvider } from './Provider' import { useAuth } from './useAuth' +import { ErrorType } from './utils' export type UserInfo = GetUserInfoRes export { AuthProvider, useAuth, + ErrorType, } diff --git a/react-sdk/src/useAuth.ts b/react-sdk/src/useAuth.ts index 1ad9a030..43c86832 100644 --- a/react-sdk/src/useAuth.ts +++ b/react-sdk/src/useAuth.ts @@ -7,6 +7,9 @@ import { exchangeTokenByRefreshToken, getUserInfo, } from 'web-sdk' import authContext, { AuthContext } from './context' +import { + ErrorType, handleError, +} from './utils' export const useAuth = () => { const context = useContext(authContext) @@ -37,9 +40,19 @@ export const useAuth = () => { () => { if (state.isAuthenticating) throw new Error('Please wait until isAuthenticating=false') if (state.isAuthenticated) throw new Error('Already authenticated, please logout first') - rawLoginRedirect(state.config) + try { + rawLoginRedirect(state.config) + } catch (e) { + const msg = handleError( + e, + ErrorType.LoginFailed, + ) + dispatch({ + type: 'setLoginError', payload: msg, + }) + } }, - [state.config, state.isAuthenticating, state.isAuthenticated], + [state.config, state.isAuthenticating, state.isAuthenticated, dispatch], ) const logoutRedirect = useCallback( @@ -52,15 +65,25 @@ export const useAuth = () => { }) => { if (!accessToken) return - await logout( - state.config, - accessToken, - refreshToken, - postLogoutRedirectUri, - localOnly, - ) + try { + await logout( + state.config, + accessToken, + refreshToken, + postLogoutRedirectUri, + localOnly, + ) + } catch (e) { + const msg = handleError( + e, + ErrorType.LogoutFailed, + ) + dispatch({ + type: 'setLogoutError', payload: msg, + }) + } }, - [state.config, accessToken, refreshToken], + [state.config, accessToken, refreshToken, dispatch], ) const acquireToken = useCallback( @@ -76,14 +99,31 @@ export const useAuth = () => { !!refreshTokenStorage?.refreshToken && currentTimeStamp < refreshTokenStorage.expiresOn - 5 if (hasValidRefreshToken) { - const res = await exchangeTokenByRefreshToken( - state.config, - refreshTokenStorage.refreshToken, - ) dispatch({ - type: 'setAccessTokenStorage', payload: res, + type: 'setIsLoadingToken', payload: true, + }) + try { + const res = await exchangeTokenByRefreshToken( + state.config, + refreshTokenStorage.refreshToken, + ) + dispatch({ + type: 'setAccessTokenStorage', payload: res, + }) + return res.accessToken + } catch (e) { + const errorMsg = handleError( + e, + ErrorType.ExchangeAccessToken, + ) + dispatch({ + type: 'setAcquireTokenError', payload: errorMsg, + }) + } + } else { + dispatch({ + type: 'setAcquireTokenError', payload: ErrorType.InvalidRefreshToken, }) - return res.accessToken } return '' @@ -96,32 +136,48 @@ export const useAuth = () => { if (state.userInfo) return state.userInfo dispatch({ - type: 'setIsLoading', payload: true, + type: 'setIsLoadingUserInfo', payload: true, }) const accessToken = await acquireToken() - const res = await getUserInfo( - state.config, - { accessToken }, - ) + try { + const res = await getUserInfo( + state.config, + { accessToken }, + ) - dispatch({ - type: 'setUserInfo', payload: res, - }) - return res + dispatch({ + type: 'setUserInfo', payload: res, + }) + return res + } catch (e) { + const errorMsg = handleError( + e, + ErrorType.FetchUserInfo, + ) + dispatch({ + type: 'setAcquireUserInfoError', payload: errorMsg, + }) + } }, [acquireToken, state.config, state.userInfo, dispatch], ) return { loginRedirect, - accessToken, refreshToken, - acquireToken, - acquireUserInfo, logoutRedirect, + accessToken, isAuthenticated, + acquireUserInfo, + acquireToken, isAuthenticating, - isLoading: state.isLoading, + isLoadingToken: state.isLoadingToken, + isLoadingUserInfo: state.isLoadingUserInfo, + authenticationError: state.authenticationError, + acquireTokenError: state.acquireTokenError, + acquireUserInfoError: state.acquireUserInfoError, + loginError: state.loginError, + logoutError: state.logoutError, } } diff --git a/react-sdk/src/utils.ts b/react-sdk/src/utils.ts new file mode 100644 index 00000000..54bd0929 --- /dev/null +++ b/react-sdk/src/utils.ts @@ -0,0 +1,18 @@ +export enum ErrorType { + Unauthorized = 'Unauthorized', + FetchUserInfo = 'Failed to fetch user info', + ExchangeAccessToken = 'Failed to exchange access token', + ObtainAccessToken = 'Can not obtain access token', + InvalidRefreshToken = 'Invalid refresh token', + LoginFailed = 'Unable to initial login flow', + LogoutFailed = 'Unable to initial logout flow', + Unknown = 'An error occurs.', +} + +export const handleError = ( + e: any, fallback?: string, +) => { + if (String(e).includes('Unauthorized')) return ErrorType.Unauthorized + if (fallback) return fallback + return ErrorType.Unknown +} diff --git a/server/src/handlers/identity.tsx b/server/src/handlers/identity.tsx index 117344f1..e65bed59 100644 --- a/server/src/handlers/identity.tsx +++ b/server/src/handlers/identity.tsx @@ -320,14 +320,16 @@ export const postLogout = async (c: Context) => { c.env.KV, bodyDto.refreshToken, ) - if (accessTokenBody.sub !== refreshTokenBody.authId) { + if (refreshTokenBody && accessTokenBody.sub !== refreshTokenBody.authId) { throw new errorConfig.Forbidden(localeConfig.Error.WrongRefreshToken) } - await kvService.invalidRefreshToken( - c.env.KV, - bodyDto.refreshToken, - ) + if (!refreshTokenBody) { + await kvService.invalidRefreshToken( + c.env.KV, + bodyDto.refreshToken, + ) + } const { AUTH_SERVER_URL } = env(c) const redirectUri = `${formatUtil.stripEndingSlash(AUTH_SERVER_URL)}${routeConfig.InternalRoute.OAuth}/logout`