diff --git a/README.md b/README.md index 59cc6c0..1d34920 100644 --- a/README.md +++ b/README.md @@ -26,18 +26,16 @@ - ✅ Sign-in / Sign-up with email ID and password - ✅ Secure session management - ✅ Email verification +- ✅ OAuth2 and OpenID compatible APIs - ✅ APIs to update profile securely - ✅ Forgot password flow using email - ✅ Social logins (Google, Github, Facebook, more coming soon) - ✅ Role-based access management -- ✅ Password-less login with email and magic link +- ✅ Password-less login with magic link login ## Roadmap -- Support more JWT encryption algorithms (Currently supporting HS256) - 2 Factor authentication -- Back office (Admin dashboard to manage user) -- Support more database - VueJS SDK - Svelte SDK - React Native SDK diff --git a/app/package-lock.json b/app/package-lock.json index 1629608..7097dcb 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@authorizerdev/authorizer-react": "latest", + "@authorizerdev/authorizer-react": "^0.17.0", "@types/react": "^17.0.15", "@types/react-dom": "^17.0.9", "esbuild": "^0.12.17", @@ -24,9 +24,9 @@ } }, "node_modules/@authorizerdev/authorizer-js": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-0.6.0.tgz", - "integrity": "sha512-WbqeUmhQwLNlvk4ZYTptlbAIINh7aZPyTCVA/B0FE3EoPtx1tNOtkPtJOycrn0H0HyueeXQnBSCDxkvPAP65Bw==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-0.10.0.tgz", + "integrity": "sha512-REM8FLD/Ej9gzA2zDGDAke6QFss33ubePlTDmLDmIYUuQmpHFlO5mCCS6nVsKkN7F/Bcwkmp+eUNQjkdGCaKLg==", "dependencies": { "node-fetch": "^2.6.1" }, @@ -35,11 +35,11 @@ } }, "node_modules/@authorizerdev/authorizer-react": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-react/-/authorizer-react-0.11.0.tgz", - "integrity": "sha512-VzSZvEB/t6N2ESn4O8c/+2hPUO7L4Iux8IBzXKrobKkoqRyb+u5TPZn0UWCOaoxIdiiZY+1Yq2A/H6q9LAqLGw==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-react/-/authorizer-react-0.17.0.tgz", + "integrity": "sha512-7WcNCU7hDFkVfFb8LcJXFwWiLYd8aY78z1AbNPxCa2Cw5G85PaRkzjKybP6h01ITVOHO6M03lLwPj8p6Sr6fEg==", "dependencies": { - "@authorizerdev/authorizer-js": "^0.6.0", + "@authorizerdev/authorizer-js": "^0.10.0", "final-form": "^4.20.2", "react-final-form": "^6.5.3", "styled-components": "^5.3.0" @@ -829,19 +829,19 @@ }, "dependencies": { "@authorizerdev/authorizer-js": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-0.6.0.tgz", - "integrity": "sha512-WbqeUmhQwLNlvk4ZYTptlbAIINh7aZPyTCVA/B0FE3EoPtx1tNOtkPtJOycrn0H0HyueeXQnBSCDxkvPAP65Bw==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-0.10.0.tgz", + "integrity": "sha512-REM8FLD/Ej9gzA2zDGDAke6QFss33ubePlTDmLDmIYUuQmpHFlO5mCCS6nVsKkN7F/Bcwkmp+eUNQjkdGCaKLg==", "requires": { "node-fetch": "^2.6.1" } }, "@authorizerdev/authorizer-react": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-react/-/authorizer-react-0.11.0.tgz", - "integrity": "sha512-VzSZvEB/t6N2ESn4O8c/+2hPUO7L4Iux8IBzXKrobKkoqRyb+u5TPZn0UWCOaoxIdiiZY+1Yq2A/H6q9LAqLGw==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-react/-/authorizer-react-0.17.0.tgz", + "integrity": "sha512-7WcNCU7hDFkVfFb8LcJXFwWiLYd8aY78z1AbNPxCa2Cw5G85PaRkzjKybP6h01ITVOHO6M03lLwPj8p6Sr6fEg==", "requires": { - "@authorizerdev/authorizer-js": "^0.6.0", + "@authorizerdev/authorizer-js": "^0.10.0", "final-form": "^4.20.2", "react-final-form": "^6.5.3", "styled-components": "^5.3.0" diff --git a/app/package.json b/app/package.json index cd974d6..8a3b954 100644 --- a/app/package.json +++ b/app/package.json @@ -11,7 +11,7 @@ "author": "Lakhan Samani", "license": "ISC", "dependencies": { - "@authorizerdev/authorizer-react": "latest", + "@authorizerdev/authorizer-react": "^0.17.0", "@types/react": "^17.0.15", "@types/react-dom": "^17.0.9", "esbuild": "^0.12.17", diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 05e2da7..001e7a9 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -10,6 +10,9 @@ const queryClient = createClient({ fetchOptions: () => { return { credentials: 'include', + headers: { + 'x-authorizer-url': window.location.origin, + }, }; }, requestPolicy: 'network-only', diff --git a/dashboard/src/components/GenerateKeysModal.tsx b/dashboard/src/components/GenerateKeysModal.tsx new file mode 100644 index 0000000..30d394b --- /dev/null +++ b/dashboard/src/components/GenerateKeysModal.tsx @@ -0,0 +1,247 @@ +import React from 'react'; +import { + Button, + Center, + Flex, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + useDisclosure, + Text, + useToast, + Input, + Spinner, +} from '@chakra-ui/react'; +import { useClient } from 'urql'; +import { FaSave } from 'react-icons/fa'; +import { + ECDSAEncryptionType, + HMACEncryptionType, + RSAEncryptionType, + SelectInputType, + TextAreaInputType, +} from '../constants'; +import InputField from './InputField'; +import { GenerateKeys, UpdateEnvVariables } from '../graphql/mutation'; + +interface propTypes { + jwtType: string; + getData: Function; +} + +interface stateVarTypes { + JWT_TYPE: string; + JWT_SECRET: string; + JWT_PRIVATE_KEY: string; + JWT_PUBLIC_KEY: string; +} + +const initState: stateVarTypes = { + JWT_TYPE: '', + JWT_SECRET: '', + JWT_PRIVATE_KEY: '', + JWT_PUBLIC_KEY: '', +}; + +const GenerateKeysModal = ({ jwtType, getData }: propTypes) => { + const client = useClient(); + const toast = useToast(); + const { isOpen, onOpen, onClose } = useDisclosure(); + const [stateVariables, setStateVariables] = React.useState({ + ...initState, + }); + const [isLoading, setIsLoading] = React.useState(false); + + React.useEffect(() => { + if (isOpen) { + setStateVariables({ ...initState, JWT_TYPE: jwtType }); + } + }, [isOpen]); + + const fetchKeys = async () => { + setIsLoading(true); + try { + const res = await client + .mutation(GenerateKeys, { params: { type: stateVariables.JWT_TYPE } }) + .toPromise(); + if (res?.error) { + toast({ + title: 'Error occurred generating jwt keys', + isClosable: true, + status: 'error', + position: 'bottom-right', + }); + closeHandler(); + } else { + setStateVariables({ + ...stateVariables, + JWT_SECRET: res?.data?._generate_jwt_keys?.secret || '', + JWT_PRIVATE_KEY: res?.data?._generate_jwt_keys?.private_key || '', + JWT_PUBLIC_KEY: res?.data?._generate_jwt_keys?.public_key || '', + }); + } + } catch (error) { + console.log(error); + } finally { + setIsLoading(false); + } + }; + + React.useEffect(() => { + if (isOpen && stateVariables.JWT_TYPE) { + fetchKeys(); + } + }, [stateVariables.JWT_TYPE]); + + const saveHandler = async () => { + const res = await client + .mutation(UpdateEnvVariables, { params: { ...stateVariables } }) + .toPromise(); + + if (res.error) { + toast({ + title: 'Error occurred setting jwt keys', + isClosable: true, + status: 'error', + position: 'bottom-right', + }); + + return; + } + toast({ + title: 'JWT keys updated successfully', + isClosable: true, + status: 'success', + position: 'bottom-right', + }); + closeHandler(); + }; + + const closeHandler = () => { + setStateVariables({ ...initState }); + getData(); + onClose(); + }; + + return ( + <> + + + + + New JWT keys + + + + + JWT Type: + + + + {isLoading ? ( +
+ +
+ ) : ( + <> + {Object.values(HMACEncryptionType).includes( + stateVariables.JWT_TYPE + ) ? ( + + + JWT Secret + +
+ + setStateVariables({ + ...stateVariables, + JWT_SECRET: event.target.value, + }) + } + readOnly + /> +
+
+ ) : ( + <> + + + Public Key + +
+ +
+
+ + + Private Key + +
+ +
+
+ + )} + + )} +
+ + + + +
+
+ + ); +}; + +export default GenerateKeysModal; diff --git a/dashboard/src/components/InviteMembersModal.tsx b/dashboard/src/components/InviteMembersModal.tsx index bd3642d..8107669 100644 --- a/dashboard/src/components/InviteMembersModal.tsx +++ b/dashboard/src/components/InviteMembersModal.tsx @@ -26,7 +26,6 @@ import { import { useClient } from 'urql'; import { FaUserPlus, FaMinusCircle, FaPlus, FaUpload } from 'react-icons/fa'; import { useDropzone } from 'react-dropzone'; -import { escape } from 'lodash'; import { validateEmail, validateURI } from '../utils'; import { InviteMembers } from '../graphql/mutation'; import { ArrayInputOperations } from '../constants'; diff --git a/dashboard/src/constants.ts b/dashboard/src/constants.ts index 5fc9e5a..e4737e5 100644 --- a/dashboard/src/constants.ts +++ b/dashboard/src/constants.ts @@ -2,6 +2,7 @@ export const LOGO_URL = 'https://user-images.githubusercontent.com/6964334/147834043-fc384cab-e7ca-40f8-9663-38fc25fd5f3a.png'; export const TextInputType = { + ACCESS_TOKEN_EXPIRY_TIME: 'ACCESS_TOKEN_EXPIRY_TIME', CLIENT_ID: 'CLIENT_ID', GOOGLE_CLIENT_ID: 'GOOGLE_CLIENT_ID', GITHUB_CLIENT_ID: 'GITHUB_CLIENT_ID', @@ -89,3 +90,41 @@ export const ECDSAEncryptionType = { ES384: 'ES384', ES512: 'ES512', }; + +export interface envVarTypes { + GOOGLE_CLIENT_ID: string; + GOOGLE_CLIENT_SECRET: string; + GITHUB_CLIENT_ID: string; + GITHUB_CLIENT_SECRET: string; + FACEBOOK_CLIENT_ID: string; + FACEBOOK_CLIENT_SECRET: string; + ROLES: [string] | []; + DEFAULT_ROLES: [string] | []; + PROTECTED_ROLES: [string] | []; + JWT_TYPE: string; + JWT_SECRET: string; + JWT_ROLE_CLAIM: string; + JWT_PRIVATE_KEY: string; + JWT_PUBLIC_KEY: string; + REDIS_URL: string; + SMTP_HOST: string; + SMTP_PORT: string; + SMTP_USERNAME: string; + SMTP_PASSWORD: string; + SENDER_EMAIL: string; + ALLOWED_ORIGINS: [string] | []; + ORGANIZATION_NAME: string; + ORGANIZATION_LOGO: string; + CUSTOM_ACCESS_TOKEN_SCRIPT: string; + ADMIN_SECRET: string; + DISABLE_LOGIN_PAGE: boolean; + DISABLE_MAGIC_LINK_LOGIN: boolean; + DISABLE_EMAIL_VERIFICATION: boolean; + DISABLE_BASIC_AUTHENTICATION: boolean; + DISABLE_SIGN_UP: boolean; + OLD_ADMIN_SECRET: string; + DATABASE_NAME: string; + DATABASE_TYPE: string; + DATABASE_URL: string; + ACCESS_TOKEN_EXPIRY_TIME: string; +} diff --git a/dashboard/src/graphql/mutation/index.ts b/dashboard/src/graphql/mutation/index.ts index df5db93..46c5fcb 100644 --- a/dashboard/src/graphql/mutation/index.ts +++ b/dashboard/src/graphql/mutation/index.ts @@ -53,3 +53,29 @@ export const InviteMembers = ` } } `; + +export const RevokeAccess = ` + mutation revokeAccess($param: UpdateAccessInput!) { + _revoke_access(param: $param) { + message + } + } +`; + +export const EnableAccess = ` + mutation revokeAccess($param: UpdateAccessInput!) { + _enable_access(param: $param) { + message + } + } +`; + +export const GenerateKeys = ` + mutation generateKeys($params: GenerateJWTKeysInput!) { + _generate_jwt_keys(params: $params) { + secret + public_key + private_key + } + } +`; diff --git a/dashboard/src/graphql/queries/index.ts b/dashboard/src/graphql/queries/index.ts index fe35528..1adf02c 100644 --- a/dashboard/src/graphql/queries/index.ts +++ b/dashboard/src/graphql/queries/index.ts @@ -53,6 +53,7 @@ export const EnvVariablesQuery = ` DATABASE_NAME, DATABASE_TYPE, DATABASE_URL, + ACCESS_TOKEN_EXPIRY_TIME, } } `; @@ -81,6 +82,7 @@ export const UserDetailsQuery = ` signup_methods roles created_at + revoked_timestamp } } } diff --git a/dashboard/src/pages/Environment.tsx b/dashboard/src/pages/Environment.tsx index f3df0e6..a6ef5a5 100644 --- a/dashboard/src/pages/Environment.tsx +++ b/dashboard/src/pages/Environment.tsx @@ -34,46 +34,11 @@ import { HMACEncryptionType, RSAEncryptionType, ECDSAEncryptionType, + envVarTypes, } from '../constants'; import { UpdateEnvVariables } from '../graphql/mutation'; import { getObjectDiff, capitalizeFirstLetter } from '../utils'; - -interface envVarTypes { - GOOGLE_CLIENT_ID: string; - GOOGLE_CLIENT_SECRET: string; - GITHUB_CLIENT_ID: string; - GITHUB_CLIENT_SECRET: string; - FACEBOOK_CLIENT_ID: string; - FACEBOOK_CLIENT_SECRET: string; - ROLES: [string] | []; - DEFAULT_ROLES: [string] | []; - PROTECTED_ROLES: [string] | []; - JWT_TYPE: string; - JWT_SECRET: string; - JWT_ROLE_CLAIM: string; - JWT_PRIVATE_KEY: string; - JWT_PUBLIC_KEY: string; - REDIS_URL: string; - SMTP_HOST: string; - SMTP_PORT: string; - SMTP_USERNAME: string; - SMTP_PASSWORD: string; - SENDER_EMAIL: string; - ALLOWED_ORIGINS: [string] | []; - ORGANIZATION_NAME: string; - ORGANIZATION_LOGO: string; - CUSTOM_ACCESS_TOKEN_SCRIPT: string; - ADMIN_SECRET: string; - DISABLE_LOGIN_PAGE: boolean; - DISABLE_MAGIC_LINK_LOGIN: boolean; - DISABLE_EMAIL_VERIFICATION: boolean; - DISABLE_BASIC_AUTHENTICATION: boolean; - DISABLE_SIGN_UP: boolean; - OLD_ADMIN_SECRET: string; - DATABASE_NAME: string; - DATABASE_TYPE: string; - DATABASE_URL: string; -} +import GenerateKeysModal from '../components/GenerateKeysModal'; export default function Environment() { const client = useClient(); @@ -120,6 +85,7 @@ export default function Environment() { DATABASE_NAME: '', DATABASE_TYPE: '', DATABASE_URL: '', + ACCESS_TOKEN_EXPIRY_TIME: '', }); const [fieldVisibility, setFieldVisibility] = React.useState< @@ -134,32 +100,24 @@ export default function Environment() { OLD_ADMIN_SECRET: false, }); + async function getData() { + const { + data: { _env: envData }, + } = await client.query(EnvVariablesQuery).toPromise(); + setLoading(false); + setEnvVariables({ + ...envData, + OLD_ADMIN_SECRET: envData.ADMIN_SECRET, + ADMIN_SECRET: '', + }); + setAdminSecret({ + value: '', + disableInputField: true, + }); + } + useEffect(() => { - let isMounted = true; - async function getData() { - const { - data: { _env: envData }, - } = await client.query(EnvVariablesQuery).toPromise(); - - if (isMounted) { - setLoading(false); - setEnvVariables({ - ...envData, - OLD_ADMIN_SECRET: envData.ADMIN_SECRET, - ADMIN_SECRET: '', - }); - setAdminSecret({ - value: '', - disableInputField: true, - }); - } - } - getData(); - - return () => { - isMounted = false; - }; }, []); const validateAdminSecretHandler = (event: any) => { @@ -230,6 +188,8 @@ export default function Environment() { disableInputField: true, }); + getData(); + toast({ title: `Successfully updated ${ Object.keys(updatedEnvVariables).length @@ -256,7 +216,7 @@ export default function Environment() { setVariables={() => {}} inputType={TextInputType.CLIENT_ID} placeholder="Client ID" - isDisabled={true} + readOnly={true} /> @@ -272,7 +232,7 @@ export default function Environment() { setFieldVisibility={setFieldVisibility} inputType={HiddenInputType.CLIENT_SECRET} placeholder="Client Secret" - isDisabled={true} + readOnly={true} /> @@ -410,9 +370,22 @@ export default function Environment() { - - JWT (JSON Web Tokens) Configurations - + + + JWT (JSON Web Tokens) Configurations + + + + + @@ -628,11 +601,28 @@ export default function Environment() { - Custom Access Token Scripts + Access Token -
+ + Access Token Expiry Time: + + + + + + + + Custom Scripts: + Used to add custom fields in ID token + + -
+
diff --git a/dashboard/src/pages/Users.tsx b/dashboard/src/pages/Users.tsx index a231117..6da5c83 100644 --- a/dashboard/src/pages/Users.tsx +++ b/dashboard/src/pages/Users.tsx @@ -39,7 +39,7 @@ import { FaAngleDown, } from 'react-icons/fa'; import { EmailVerificationQuery, UserDetailsQuery } from '../graphql/queries'; -import { UpdateUser } from '../graphql/mutation'; +import { EnableAccess, RevokeAccess, UpdateUser } from '../graphql/mutation'; import EditUserModal from '../components/EditUserModal'; import DeleteUserModal from '../components/DeleteUserModal'; import InviteMembersModal from '../components/InviteMembersModal'; @@ -67,6 +67,12 @@ interface userDataTypes { signup_methods: string; roles: [string]; created_at: number; + revoked_timestamp: number; +} + +const enum updateAccessActions { + REVOKE = 'REVOKE', + ENABLE = 'ENABLE', } const getMaxPages = (pagination: paginationPropTypes) => { @@ -185,6 +191,66 @@ export default function Users() { updateUserList(); }; + const updateAccessHandler = async ( + id: string, + action: updateAccessActions + ) => { + switch (action) { + case updateAccessActions.ENABLE: + const enableAccessRes = await client + .mutation(EnableAccess, { + param: { + user_id: id, + }, + }) + .toPromise(); + if (enableAccessRes.error) { + toast({ + title: 'User access enable failed', + isClosable: true, + status: 'error', + position: 'bottom-right', + }); + } else { + toast({ + title: 'User access enabled successfully', + isClosable: true, + status: 'success', + position: 'bottom-right', + }); + } + updateUserList(); + break; + case updateAccessActions.REVOKE: + const revokeAccessRes = await client + .mutation(RevokeAccess, { + param: { + user_id: id, + }, + }) + .toPromise(); + if (revokeAccessRes.error) { + toast({ + title: 'User access revoke failed', + isClosable: true, + status: 'error', + position: 'bottom-right', + }); + } else { + toast({ + title: 'User access revoked successfully', + isClosable: true, + status: 'success', + position: 'bottom-right', + }); + } + updateUserList(); + break; + default: + break; + } + }; + return ( @@ -206,6 +272,7 @@ export default function Users() { Signup Methods Roles Verified + Access Actions @@ -214,7 +281,7 @@ export default function Users() { const { email_verified, created_at, ...rest }: any = user; return ( - {user.email} + {user.email} {dayjs(user.created_at * 1000).format('MMM DD, YYYY')} @@ -229,6 +296,15 @@ export default function Users() { {user.email_verified.toString()} + + + {user.revoked_timestamp ? 'Revoked' : 'Enabled'} + + @@ -258,6 +334,29 @@ export default function Users() { user={rest} updateUserList={updateUserList} /> + {user.revoked_timestamp ? ( + + updateAccessHandler( + user.id, + updateAccessActions.ENABLE + ) + } + > + Enable Access + + ) : ( + + updateAccessHandler( + user.id, + updateAccessActions.REVOKE + ) + } + > + Revoke Access + + )} diff --git a/server/constants/env.go b/server/constants/env.go index f391750..66b466a 100644 --- a/server/constants/env.go +++ b/server/constants/env.go @@ -16,11 +16,12 @@ const ( // EnvKeyEnvPath key for cli arg variable ENV_PATH EnvKeyEnvPath = "ENV_PATH" // EnvKeyAuthorizerURL key for env variable AUTHORIZER_URL - // TODO: remove support AUTHORIZER_URL env EnvKeyAuthorizerURL = "AUTHORIZER_URL" // EnvKeyPort key for env variable PORT EnvKeyPort = "PORT" + // EnvKeyAccessTokenExpiryTime key for env variable ACCESS_TOKEN_EXPIRY_TIME + EnvKeyAccessTokenExpiryTime = "ACCESS_TOKEN_EXPIRY_TIME" // EnvKeyAdminSecret key for env variable ADMIN_SECRET EnvKeyAdminSecret = "ADMIN_SECRET" // EnvKeyDatabaseType key for env variable DATABASE_TYPE diff --git a/server/db/models/user.go b/server/db/models/user.go index 9208bea..38b1fb1 100644 --- a/server/db/models/user.go +++ b/server/db/models/user.go @@ -27,6 +27,7 @@ type User struct { Roles string `json:"roles" bson:"roles" cql:"roles"` UpdatedAt int64 `json:"updated_at" bson:"updated_at" cql:"updated_at"` CreatedAt int64 `json:"created_at" bson:"created_at" cql:"created_at"` + RevokedTimestamp *int64 `json:"revoked_timestamp" bson:"revoked_timestamp" cql:"revoked_timestamp"` } func (user *User) AsAPIUser() *model.User { @@ -35,6 +36,7 @@ func (user *User) AsAPIUser() *model.User { email := user.Email createdAt := user.CreatedAt updatedAt := user.UpdatedAt + revokedTimestamp := user.RevokedTimestamp return &model.User{ ID: user.ID, Email: user.Email, @@ -53,5 +55,6 @@ func (user *User) AsAPIUser() *model.User { Roles: strings.Split(user.Roles, ","), CreatedAt: &createdAt, UpdatedAt: &updatedAt, + RevokedTimestamp: revokedTimestamp, } } diff --git a/server/db/models/verification_requests.go b/server/db/models/verification_requests.go index b9a3797..5addba3 100644 --- a/server/db/models/verification_requests.go +++ b/server/db/models/verification_requests.go @@ -4,14 +4,14 @@ import "github.com/authorizerdev/authorizer/server/graph/model" // VerificationRequest model for db type VerificationRequest struct { - Key string `json:"_key,omitempty" bson:"_key,omitempty" cql:"_key,omitempty"` // for arangodb + Key string `json:"_key,omitempty" bson:"_key" cql:"_key,omitempty"` // for arangodb ID string `gorm:"primaryKey;type:char(36)" json:"_id" bson:"_id" cql:"id"` Token string `gorm:"type:text" json:"token" bson:"token" cql:"token"` - Identifier string `gorm:"uniqueIndex:idx_email_identifier" json:"identifier" bson:"identifier" cql:"identifier"` + Identifier string `gorm:"uniqueIndex:idx_email_identifier;type:varchar(64)" json:"identifier" bson:"identifier" cql:"identifier"` ExpiresAt int64 `json:"expires_at" bson:"expires_at" cql:"expires_at"` CreatedAt int64 `json:"created_at" bson:"created_at" cql:"created_at"` UpdatedAt int64 `json:"updated_at" bson:"updated_at" cql:"updated_at"` - Email string `gorm:"uniqueIndex:idx_email_identifier" json:"email" bson:"email" cql:"email"` + Email string `gorm:"uniqueIndex:idx_email_identifier;type:varchar(256)" json:"email" bson:"email" cql:"email"` Nonce string `gorm:"type:text" json:"nonce" bson:"nonce" cql:"nonce"` RedirectURI string `gorm:"type:text" json:"redirect_uri" bson:"redirect_uri" cql:"redirect_uri"` } diff --git a/server/env/env.go b/server/env/env.go index 8770441..7202d9b 100644 --- a/server/env/env.go +++ b/server/env/env.go @@ -113,6 +113,10 @@ func InitAllEnv() error { envData.StringEnv[constants.EnvKeyAppURL] = os.Getenv(constants.EnvKeyAppURL) } + if envData.StringEnv[constants.EnvKeyAuthorizerURL] == "" { + envData.StringEnv[constants.EnvKeyAuthorizerURL] = os.Getenv(constants.EnvKeyAuthorizerURL) + } + if envData.StringEnv[constants.EnvKeyPort] == "" { envData.StringEnv[constants.EnvKeyPort] = os.Getenv(constants.EnvKeyPort) if envData.StringEnv[constants.EnvKeyPort] == "" { @@ -120,6 +124,13 @@ func InitAllEnv() error { } } + if envData.StringEnv[constants.EnvKeyAccessTokenExpiryTime] == "" { + envData.StringEnv[constants.EnvKeyAccessTokenExpiryTime] = os.Getenv(constants.EnvKeyAccessTokenExpiryTime) + if envData.StringEnv[constants.EnvKeyAccessTokenExpiryTime] == "" { + envData.StringEnv[constants.EnvKeyAccessTokenExpiryTime] = "30m" + } + } + if envData.StringEnv[constants.EnvKeyAdminSecret] == "" { envData.StringEnv[constants.EnvKeyAdminSecret] = os.Getenv(constants.EnvKeyAdminSecret) } diff --git a/server/env/persist_env.go b/server/env/persist_env.go index 42ba969..82283d7 100644 --- a/server/env/persist_env.go +++ b/server/env/persist_env.go @@ -165,6 +165,7 @@ func PersistEnv() error { hasChanged = true } } + envstore.EnvStoreObj.UpdateEnvStore(storeData) jwk, err := crypto.GenerateJWKBasedOnEnv() if err != nil { diff --git a/server/graph/generated/generated.go b/server/graph/generated/generated.go index 3dd7e42..e33e660 100644 --- a/server/graph/generated/generated.go +++ b/server/graph/generated/generated.go @@ -53,6 +53,7 @@ type ComplexityRoot struct { } Env struct { + AccessTokenExpiryTime func(childComplexity int) int AdminSecret func(childComplexity int) int AllowedOrigins func(childComplexity int) int AppURL func(childComplexity int) int @@ -98,6 +99,12 @@ type ComplexityRoot struct { Reason func(childComplexity int) int } + GenerateJWTKeysResponse struct { + PrivateKey func(childComplexity int) int + PublicKey func(childComplexity int) int + Secret func(childComplexity int) int + } + Meta struct { ClientID func(childComplexity int) int IsBasicAuthenticationEnabled func(childComplexity int) int @@ -115,7 +122,9 @@ type ComplexityRoot struct { AdminLogout func(childComplexity int) int AdminSignup func(childComplexity int, params model.AdminSignupInput) int DeleteUser func(childComplexity int, params model.DeleteUserInput) int + EnableAccess func(childComplexity int, param model.UpdateAccessInput) int ForgotPassword func(childComplexity int, params model.ForgotPasswordInput) int + GenerateJwtKeys func(childComplexity int, params model.GenerateJWTKeysInput) int InviteMembers func(childComplexity int, params model.InviteMemberInput) int Login func(childComplexity int, params model.LoginInput) int Logout func(childComplexity int) int @@ -123,6 +132,7 @@ type ComplexityRoot struct { ResendVerifyEmail func(childComplexity int, params model.ResendVerifyEmailInput) int ResetPassword func(childComplexity int, params model.ResetPasswordInput) int Revoke func(childComplexity int, params model.OAuthRevokeInput) int + RevokeAccess func(childComplexity int, param model.UpdateAccessInput) int Signup func(childComplexity int, params model.SignUpInput) int UpdateEnv func(childComplexity int, params model.UpdateEnvInput) int UpdateProfile func(childComplexity int, params model.UpdateProfileInput) int @@ -144,6 +154,7 @@ type ComplexityRoot struct { Profile func(childComplexity int) int Session func(childComplexity int, params *model.SessionQueryInput) int Users func(childComplexity int, params *model.PaginatedInput) int + ValidateJwtToken func(childComplexity int, params model.ValidateJWTTokenInput) int VerificationRequests func(childComplexity int, params *model.PaginatedInput) int } @@ -166,6 +177,7 @@ type ComplexityRoot struct { PhoneNumberVerified func(childComplexity int) int Picture func(childComplexity int) int PreferredUsername func(childComplexity int) int + RevokedTimestamp func(childComplexity int) int Roles func(childComplexity int) int SignupMethods func(childComplexity int) int UpdatedAt func(childComplexity int) int @@ -176,6 +188,10 @@ type ComplexityRoot struct { Users func(childComplexity int) int } + ValidateJWTTokenResponse struct { + IsValid func(childComplexity int) int + } + VerificationRequest struct { CreatedAt func(childComplexity int) int Email func(childComplexity int) int @@ -212,11 +228,15 @@ type MutationResolver interface { AdminLogout(ctx context.Context) (*model.Response, error) UpdateEnv(ctx context.Context, params model.UpdateEnvInput) (*model.Response, error) InviteMembers(ctx context.Context, params model.InviteMemberInput) (*model.Response, error) + RevokeAccess(ctx context.Context, param model.UpdateAccessInput) (*model.Response, error) + EnableAccess(ctx context.Context, param model.UpdateAccessInput) (*model.Response, error) + GenerateJwtKeys(ctx context.Context, params model.GenerateJWTKeysInput) (*model.GenerateJWTKeysResponse, error) } type QueryResolver interface { Meta(ctx context.Context) (*model.Meta, error) Session(ctx context.Context, params *model.SessionQueryInput) (*model.AuthResponse, error) Profile(ctx context.Context) (*model.User, error) + ValidateJwtToken(ctx context.Context, params model.ValidateJWTTokenInput) (*model.ValidateJWTTokenResponse, error) Users(ctx context.Context, params *model.PaginatedInput) (*model.Users, error) VerificationRequests(ctx context.Context, params *model.PaginatedInput) (*model.VerificationRequests, error) AdminSession(ctx context.Context) (*model.Response, error) @@ -280,6 +300,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.AuthResponse.User(childComplexity), true + case "Env.ACCESS_TOKEN_EXPIRY_TIME": + if e.complexity.Env.AccessTokenExpiryTime == nil { + break + } + + return e.complexity.Env.AccessTokenExpiryTime(childComplexity), true + case "Env.ADMIN_SECRET": if e.complexity.Env.AdminSecret == nil { break @@ -560,6 +587,27 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Error.Reason(childComplexity), true + case "GenerateJWTKeysResponse.private_key": + if e.complexity.GenerateJWTKeysResponse.PrivateKey == nil { + break + } + + return e.complexity.GenerateJWTKeysResponse.PrivateKey(childComplexity), true + + case "GenerateJWTKeysResponse.public_key": + if e.complexity.GenerateJWTKeysResponse.PublicKey == nil { + break + } + + return e.complexity.GenerateJWTKeysResponse.PublicKey(childComplexity), true + + case "GenerateJWTKeysResponse.secret": + if e.complexity.GenerateJWTKeysResponse.Secret == nil { + break + } + + return e.complexity.GenerateJWTKeysResponse.Secret(childComplexity), true + case "Meta.client_id": if e.complexity.Meta.ClientID == nil { break @@ -666,6 +714,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.DeleteUser(childComplexity, args["params"].(model.DeleteUserInput)), true + case "Mutation._enable_access": + if e.complexity.Mutation.EnableAccess == nil { + break + } + + args, err := ec.field_Mutation__enable_access_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.EnableAccess(childComplexity, args["param"].(model.UpdateAccessInput)), true + case "Mutation.forgot_password": if e.complexity.Mutation.ForgotPassword == nil { break @@ -678,6 +738,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.ForgotPassword(childComplexity, args["params"].(model.ForgotPasswordInput)), true + case "Mutation._generate_jwt_keys": + if e.complexity.Mutation.GenerateJwtKeys == nil { + break + } + + args, err := ec.field_Mutation__generate_jwt_keys_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.GenerateJwtKeys(childComplexity, args["params"].(model.GenerateJWTKeysInput)), true + case "Mutation._invite_members": if e.complexity.Mutation.InviteMembers == nil { break @@ -757,6 +829,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.Revoke(childComplexity, args["params"].(model.OAuthRevokeInput)), true + case "Mutation._revoke_access": + if e.complexity.Mutation.RevokeAccess == nil { + break + } + + args, err := ec.field_Mutation__revoke_access_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.RevokeAccess(childComplexity, args["param"].(model.UpdateAccessInput)), true + case "Mutation.signup": if e.complexity.Mutation.Signup == nil { break @@ -897,6 +981,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Query.Users(childComplexity, args["params"].(*model.PaginatedInput)), true + case "Query.validate_jwt_token": + if e.complexity.Query.ValidateJwtToken == nil { + break + } + + args, err := ec.field_Query_validate_jwt_token_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.ValidateJwtToken(childComplexity, args["params"].(model.ValidateJWTTokenInput)), true + case "Query._verification_requests": if e.complexity.Query.VerificationRequests == nil { break @@ -1014,6 +1110,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.User.PreferredUsername(childComplexity), true + case "User.revoked_timestamp": + if e.complexity.User.RevokedTimestamp == nil { + break + } + + return e.complexity.User.RevokedTimestamp(childComplexity), true + case "User.roles": if e.complexity.User.Roles == nil { break @@ -1049,6 +1152,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Users.Users(childComplexity), true + case "ValidateJWTTokenResponse.is_valid": + if e.complexity.ValidateJWTTokenResponse.IsValid == nil { + break + } + + return e.complexity.ValidateJWTTokenResponse.IsValid(childComplexity), true + case "VerificationRequest.created_at": if e.complexity.VerificationRequest.CreatedAt == nil { break @@ -1235,6 +1345,7 @@ type User { roles: [String!]! created_at: Int64 updated_at: Int64 + revoked_timestamp: Int64 } type Users { @@ -1278,6 +1389,7 @@ type Response { } type Env { + ACCESS_TOKEN_EXPIRY_TIME: String ADMIN_SECRET: String DATABASE_NAME: String! DATABASE_URL: String! @@ -1318,7 +1430,18 @@ type Env { ORGANIZATION_LOGO: String } +type ValidateJWTTokenResponse { + is_valid: Boolean! +} + +type GenerateJWTKeysResponse { + secret: String + public_key: String + private_key: String +} + input UpdateEnvInput { + ACCESS_TOKEN_EXPIRY_TIME: String ADMIN_SECRET: String CUSTOM_ACCESS_TOKEN_SCRIPT: String OLD_ADMIN_SECRET: String @@ -1473,6 +1596,20 @@ input InviteMemberInput { redirect_uri: String } +input UpdateAccessInput { + user_id: String! +} + +input ValidateJWTTokenInput { + token_type: String! + token: String! + roles: [String!] +} + +input GenerateJWTKeysInput { + type: String! +} + type Mutation { signup(params: SignUpInput!): AuthResponse! login(params: LoginInput!): AuthResponse! @@ -1492,12 +1629,16 @@ type Mutation { _admin_logout: Response! _update_env(params: UpdateEnvInput!): Response! _invite_members(params: InviteMemberInput!): Response! + _revoke_access(param: UpdateAccessInput!): Response! + _enable_access(param: UpdateAccessInput!): Response! + _generate_jwt_keys(params: GenerateJWTKeysInput!): GenerateJWTKeysResponse! } type Query { meta: Meta! session(params: SessionQueryInput): AuthResponse! profile: User! + validate_jwt_token(params: ValidateJWTTokenInput!): ValidateJWTTokenResponse! # admin only apis _users(params: PaginatedInput): Users! _verification_requests(params: PaginatedInput): VerificationRequests! @@ -1557,6 +1698,36 @@ func (ec *executionContext) field_Mutation__delete_user_args(ctx context.Context return args, nil } +func (ec *executionContext) field_Mutation__enable_access_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 model.UpdateAccessInput + if tmp, ok := rawArgs["param"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("param")) + arg0, err = ec.unmarshalNUpdateAccessInput2githubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐUpdateAccessInput(ctx, tmp) + if err != nil { + return nil, err + } + } + args["param"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Mutation__generate_jwt_keys_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 model.GenerateJWTKeysInput + if tmp, ok := rawArgs["params"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("params")) + arg0, err = ec.unmarshalNGenerateJWTKeysInput2githubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐGenerateJWTKeysInput(ctx, tmp) + if err != nil { + return nil, err + } + } + args["params"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation__invite_members_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -1572,6 +1743,21 @@ func (ec *executionContext) field_Mutation__invite_members_args(ctx context.Cont return args, nil } +func (ec *executionContext) field_Mutation__revoke_access_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 model.UpdateAccessInput + if tmp, ok := rawArgs["param"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("param")) + arg0, err = ec.unmarshalNUpdateAccessInput2githubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐUpdateAccessInput(ctx, tmp) + if err != nil { + return nil, err + } + } + args["param"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation__update_env_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -1797,6 +1983,21 @@ func (ec *executionContext) field_Query_session_args(ctx context.Context, rawArg return args, nil } +func (ec *executionContext) field_Query_validate_jwt_token_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 model.ValidateJWTTokenInput + if tmp, ok := rawArgs["params"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("params")) + arg0, err = ec.unmarshalNValidateJWTTokenInput2githubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐValidateJWTTokenInput(ctx, tmp) + if err != nil { + return nil, err + } + } + args["params"] = arg0 + return args, nil +} + func (ec *executionContext) field___Type_enumValues_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -2030,6 +2231,38 @@ func (ec *executionContext) _AuthResponse_user(ctx context.Context, field graphq return ec.marshalOUser2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐUser(ctx, field.Selections, res) } +func (ec *executionContext) _Env_ACCESS_TOKEN_EXPIRY_TIME(ctx context.Context, field graphql.CollectedField, obj *model.Env) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Env", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.AccessTokenExpiryTime, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + func (ec *executionContext) _Env_ADMIN_SECRET(ctx context.Context, field graphql.CollectedField, obj *model.Env) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -3331,6 +3564,102 @@ func (ec *executionContext) _Error_reason(ctx context.Context, field graphql.Col return ec.marshalNString2string(ctx, field.Selections, res) } +func (ec *executionContext) _GenerateJWTKeysResponse_secret(ctx context.Context, field graphql.CollectedField, obj *model.GenerateJWTKeysResponse) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "GenerateJWTKeysResponse", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Secret, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) _GenerateJWTKeysResponse_public_key(ctx context.Context, field graphql.CollectedField, obj *model.GenerateJWTKeysResponse) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "GenerateJWTKeysResponse", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.PublicKey, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) _GenerateJWTKeysResponse_private_key(ctx context.Context, field graphql.CollectedField, obj *model.GenerateJWTKeysResponse) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "GenerateJWTKeysResponse", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.PrivateKey, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + func (ec *executionContext) _Meta_version(ctx context.Context, field graphql.CollectedField, obj *model.Meta) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -4346,6 +4675,132 @@ func (ec *executionContext) _Mutation__invite_members(ctx context.Context, field return ec.marshalNResponse2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐResponse(ctx, field.Selections, res) } +func (ec *executionContext) _Mutation__revoke_access(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Mutation", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Mutation__revoke_access_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().RevokeAccess(rctx, args["param"].(model.UpdateAccessInput)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*model.Response) + fc.Result = res + return ec.marshalNResponse2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐResponse(ctx, field.Selections, res) +} + +func (ec *executionContext) _Mutation__enable_access(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Mutation", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Mutation__enable_access_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().EnableAccess(rctx, args["param"].(model.UpdateAccessInput)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*model.Response) + fc.Result = res + return ec.marshalNResponse2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐResponse(ctx, field.Selections, res) +} + +func (ec *executionContext) _Mutation__generate_jwt_keys(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Mutation", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Mutation__generate_jwt_keys_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().GenerateJwtKeys(rctx, args["params"].(model.GenerateJWTKeysInput)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*model.GenerateJWTKeysResponse) + fc.Result = res + return ec.marshalNGenerateJWTKeysResponse2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐGenerateJWTKeysResponse(ctx, field.Selections, res) +} + func (ec *executionContext) _Pagination_limit(ctx context.Context, field graphql.CollectedField, obj *model.Pagination) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -4598,6 +5053,48 @@ func (ec *executionContext) _Query_profile(ctx context.Context, field graphql.Co return ec.marshalNUser2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐUser(ctx, field.Selections, res) } +func (ec *executionContext) _Query_validate_jwt_token(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Query", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Query_validate_jwt_token_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().ValidateJwtToken(rctx, args["params"].(model.ValidateJWTTokenInput)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*model.ValidateJWTTokenResponse) + fc.Result = res + return ec.marshalNValidateJWTTokenResponse2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐValidateJWTTokenResponse(ctx, field.Selections, res) +} + func (ec *executionContext) _Query__users(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -5417,6 +5914,38 @@ func (ec *executionContext) _User_updated_at(ctx context.Context, field graphql. return ec.marshalOInt642ᚖint64(ctx, field.Selections, res) } +func (ec *executionContext) _User_revoked_timestamp(ctx context.Context, field graphql.CollectedField, obj *model.User) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "User", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.RevokedTimestamp, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*int64) + fc.Result = res + return ec.marshalOInt642ᚖint64(ctx, field.Selections, res) +} + func (ec *executionContext) _Users_pagination(ctx context.Context, field graphql.CollectedField, obj *model.Users) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -5487,6 +6016,41 @@ func (ec *executionContext) _Users_users(ctx context.Context, field graphql.Coll return ec.marshalNUser2ᚕᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐUserᚄ(ctx, field.Selections, res) } +func (ec *executionContext) _ValidateJWTTokenResponse_is_valid(ctx context.Context, field graphql.CollectedField, obj *model.ValidateJWTTokenResponse) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "ValidateJWTTokenResponse", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.IsValid, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + func (ec *executionContext) _VerificationRequest_id(ctx context.Context, field graphql.CollectedField, obj *model.VerificationRequest) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -7078,6 +7642,29 @@ func (ec *executionContext) unmarshalInputForgotPasswordInput(ctx context.Contex return it, nil } +func (ec *executionContext) unmarshalInputGenerateJWTKeysInput(ctx context.Context, obj interface{}) (model.GenerateJWTKeysInput, error) { + var it model.GenerateJWTKeysInput + asMap := map[string]interface{}{} + for k, v := range obj.(map[string]interface{}) { + asMap[k] = v + } + + for k, v := range asMap { + switch k { + case "type": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("type")) + it.Type, err = ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputInviteMemberInput(ctx context.Context, obj interface{}) (model.InviteMemberInput, error) { var it model.InviteMemberInput asMap := map[string]interface{}{} @@ -7516,6 +8103,29 @@ func (ec *executionContext) unmarshalInputSignUpInput(ctx context.Context, obj i return it, nil } +func (ec *executionContext) unmarshalInputUpdateAccessInput(ctx context.Context, obj interface{}) (model.UpdateAccessInput, error) { + var it model.UpdateAccessInput + asMap := map[string]interface{}{} + for k, v := range obj.(map[string]interface{}) { + asMap[k] = v + } + + for k, v := range asMap { + switch k { + case "user_id": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("user_id")) + it.UserID, err = ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputUpdateEnvInput(ctx context.Context, obj interface{}) (model.UpdateEnvInput, error) { var it model.UpdateEnvInput asMap := map[string]interface{}{} @@ -7525,6 +8135,14 @@ func (ec *executionContext) unmarshalInputUpdateEnvInput(ctx context.Context, ob for k, v := range asMap { switch k { + case "ACCESS_TOKEN_EXPIRY_TIME": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("ACCESS_TOKEN_EXPIRY_TIME")) + it.AccessTokenExpiryTime, err = ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } case "ADMIN_SECRET": var err error @@ -8025,6 +8643,45 @@ func (ec *executionContext) unmarshalInputUpdateUserInput(ctx context.Context, o return it, nil } +func (ec *executionContext) unmarshalInputValidateJWTTokenInput(ctx context.Context, obj interface{}) (model.ValidateJWTTokenInput, error) { + var it model.ValidateJWTTokenInput + asMap := map[string]interface{}{} + for k, v := range obj.(map[string]interface{}) { + asMap[k] = v + } + + for k, v := range asMap { + switch k { + case "token_type": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("token_type")) + it.TokenType, err = ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + case "token": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("token")) + it.Token, err = ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + case "roles": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("roles")) + it.Roles, err = ec.unmarshalOString2ᚕstringᚄ(ctx, v) + if err != nil { + return it, err + } + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputVerifyEmailInput(ctx context.Context, obj interface{}) (model.VerifyEmailInput, error) { var it model.VerifyEmailInput asMap := map[string]interface{}{} @@ -8104,6 +8761,8 @@ func (ec *executionContext) _Env(ctx context.Context, sel ast.SelectionSet, obj switch field.Name { case "__typename": out.Values[i] = graphql.MarshalString("Env") + case "ACCESS_TOKEN_EXPIRY_TIME": + out.Values[i] = ec._Env_ACCESS_TOKEN_EXPIRY_TIME(ctx, field, obj) case "ADMIN_SECRET": out.Values[i] = ec._Env_ADMIN_SECRET(ctx, field, obj) case "DATABASE_NAME": @@ -8238,6 +8897,34 @@ func (ec *executionContext) _Error(ctx context.Context, sel ast.SelectionSet, ob return out } +var generateJWTKeysResponseImplementors = []string{"GenerateJWTKeysResponse"} + +func (ec *executionContext) _GenerateJWTKeysResponse(ctx context.Context, sel ast.SelectionSet, obj *model.GenerateJWTKeysResponse) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, generateJWTKeysResponseImplementors) + + out := graphql.NewFieldSet(fields) + var invalids uint32 + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("GenerateJWTKeysResponse") + case "secret": + out.Values[i] = ec._GenerateJWTKeysResponse_secret(ctx, field, obj) + case "public_key": + out.Values[i] = ec._GenerateJWTKeysResponse_public_key(ctx, field, obj) + case "private_key": + out.Values[i] = ec._GenerateJWTKeysResponse_private_key(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch() + if invalids > 0 { + return graphql.Null + } + return out +} + var metaImplementors = []string{"Meta"} func (ec *executionContext) _Meta(ctx context.Context, sel ast.SelectionSet, obj *model.Meta) graphql.Marshaler { @@ -8405,6 +9092,21 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { invalids++ } + case "_revoke_access": + out.Values[i] = ec._Mutation__revoke_access(ctx, field) + if out.Values[i] == graphql.Null { + invalids++ + } + case "_enable_access": + out.Values[i] = ec._Mutation__enable_access(ctx, field) + if out.Values[i] == graphql.Null { + invalids++ + } + case "_generate_jwt_keys": + out.Values[i] = ec._Mutation__generate_jwt_keys(ctx, field) + if out.Values[i] == graphql.Null { + invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -8515,6 +9217,20 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr } return res }) + case "validate_jwt_token": + field := field + out.Concurrently(i, func() (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_validate_jwt_token(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&invalids, 1) + } + return res + }) case "_users": field := field out.Concurrently(i, func() (res graphql.Marshaler) { @@ -8673,6 +9389,8 @@ func (ec *executionContext) _User(ctx context.Context, sel ast.SelectionSet, obj out.Values[i] = ec._User_created_at(ctx, field, obj) case "updated_at": out.Values[i] = ec._User_updated_at(ctx, field, obj) + case "revoked_timestamp": + out.Values[i] = ec._User_revoked_timestamp(ctx, field, obj) default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -8716,6 +9434,33 @@ func (ec *executionContext) _Users(ctx context.Context, sel ast.SelectionSet, ob return out } +var validateJWTTokenResponseImplementors = []string{"ValidateJWTTokenResponse"} + +func (ec *executionContext) _ValidateJWTTokenResponse(ctx context.Context, sel ast.SelectionSet, obj *model.ValidateJWTTokenResponse) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, validateJWTTokenResponseImplementors) + + out := graphql.NewFieldSet(fields) + var invalids uint32 + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ValidateJWTTokenResponse") + case "is_valid": + out.Values[i] = ec._ValidateJWTTokenResponse_is_valid(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch() + if invalids > 0 { + return graphql.Null + } + return out +} + var verificationRequestImplementors = []string{"VerificationRequest"} func (ec *executionContext) _VerificationRequest(ctx context.Context, sel ast.SelectionSet, obj *model.VerificationRequest) graphql.Marshaler { @@ -9104,6 +9849,25 @@ func (ec *executionContext) unmarshalNForgotPasswordInput2githubᚗcomᚋauthori return res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) unmarshalNGenerateJWTKeysInput2githubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐGenerateJWTKeysInput(ctx context.Context, v interface{}) (model.GenerateJWTKeysInput, error) { + res, err := ec.unmarshalInputGenerateJWTKeysInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNGenerateJWTKeysResponse2githubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐGenerateJWTKeysResponse(ctx context.Context, sel ast.SelectionSet, v model.GenerateJWTKeysResponse) graphql.Marshaler { + return ec._GenerateJWTKeysResponse(ctx, sel, &v) +} + +func (ec *executionContext) marshalNGenerateJWTKeysResponse2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐGenerateJWTKeysResponse(ctx context.Context, sel ast.SelectionSet, v *model.GenerateJWTKeysResponse) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + return ec._GenerateJWTKeysResponse(ctx, sel, v) +} + func (ec *executionContext) unmarshalNID2string(ctx context.Context, v interface{}) (string, error) { res, err := graphql.UnmarshalID(v) return res, graphql.ErrorOnPath(ctx, err) @@ -9258,6 +10022,11 @@ func (ec *executionContext) marshalNString2ᚕstringᚄ(ctx context.Context, sel return ret } +func (ec *executionContext) unmarshalNUpdateAccessInput2githubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐUpdateAccessInput(ctx context.Context, v interface{}) (model.UpdateAccessInput, error) { + res, err := ec.unmarshalInputUpdateAccessInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) unmarshalNUpdateEnvInput2githubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐUpdateEnvInput(ctx context.Context, v interface{}) (model.UpdateEnvInput, error) { res, err := ec.unmarshalInputUpdateEnvInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) @@ -9345,6 +10114,25 @@ func (ec *executionContext) marshalNUsers2ᚖgithubᚗcomᚋauthorizerdevᚋauth return ec._Users(ctx, sel, v) } +func (ec *executionContext) unmarshalNValidateJWTTokenInput2githubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐValidateJWTTokenInput(ctx context.Context, v interface{}) (model.ValidateJWTTokenInput, error) { + res, err := ec.unmarshalInputValidateJWTTokenInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNValidateJWTTokenResponse2githubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐValidateJWTTokenResponse(ctx context.Context, sel ast.SelectionSet, v model.ValidateJWTTokenResponse) graphql.Marshaler { + return ec._ValidateJWTTokenResponse(ctx, sel, &v) +} + +func (ec *executionContext) marshalNValidateJWTTokenResponse2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐValidateJWTTokenResponse(ctx context.Context, sel ast.SelectionSet, v *model.ValidateJWTTokenResponse) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + return ec._ValidateJWTTokenResponse(ctx, sel, v) +} + func (ec *executionContext) marshalNVerificationRequest2ᚕᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐVerificationRequestᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.VerificationRequest) graphql.Marshaler { ret := make(graphql.Array, len(v)) var wg sync.WaitGroup diff --git a/server/graph/model/models_gen.go b/server/graph/model/models_gen.go index 1ebad63..09468b4 100644 --- a/server/graph/model/models_gen.go +++ b/server/graph/model/models_gen.go @@ -24,6 +24,7 @@ type DeleteUserInput struct { } type Env struct { + AccessTokenExpiryTime *string `json:"ACCESS_TOKEN_EXPIRY_TIME"` AdminSecret *string `json:"ADMIN_SECRET"` DatabaseName string `json:"DATABASE_NAME"` DatabaseURL string `json:"DATABASE_URL"` @@ -75,6 +76,16 @@ type ForgotPasswordInput struct { RedirectURI *string `json:"redirect_uri"` } +type GenerateJWTKeysInput struct { + Type string `json:"type"` +} + +type GenerateJWTKeysResponse struct { + Secret *string `json:"secret"` + PublicKey *string `json:"public_key"` + PrivateKey *string `json:"private_key"` +} + type InviteMemberInput struct { Emails []string `json:"emails"` RedirectURI *string `json:"redirect_uri"` @@ -164,7 +175,12 @@ type SignUpInput struct { RedirectURI *string `json:"redirect_uri"` } +type UpdateAccessInput struct { + UserID string `json:"user_id"` +} + type UpdateEnvInput struct { + AccessTokenExpiryTime *string `json:"ACCESS_TOKEN_EXPIRY_TIME"` AdminSecret *string `json:"ADMIN_SECRET"` CustomAccessTokenScript *string `json:"CUSTOM_ACCESS_TOKEN_SCRIPT"` OldAdminSecret *string `json:"OLD_ADMIN_SECRET"` @@ -249,6 +265,7 @@ type User struct { Roles []string `json:"roles"` CreatedAt *int64 `json:"created_at"` UpdatedAt *int64 `json:"updated_at"` + RevokedTimestamp *int64 `json:"revoked_timestamp"` } type Users struct { @@ -256,6 +273,16 @@ type Users struct { Users []*User `json:"users"` } +type ValidateJWTTokenInput struct { + TokenType string `json:"token_type"` + Token string `json:"token"` + Roles []string `json:"roles"` +} + +type ValidateJWTTokenResponse struct { + IsValid bool `json:"is_valid"` +} + type VerificationRequest struct { ID string `json:"id"` Identifier *string `json:"identifier"` diff --git a/server/graph/schema.graphqls b/server/graph/schema.graphqls index 13f2a1b..841ad8c 100644 --- a/server/graph/schema.graphqls +++ b/server/graph/schema.graphqls @@ -43,6 +43,7 @@ type User { roles: [String!]! created_at: Int64 updated_at: Int64 + revoked_timestamp: Int64 } type Users { @@ -86,6 +87,7 @@ type Response { } type Env { + ACCESS_TOKEN_EXPIRY_TIME: String ADMIN_SECRET: String DATABASE_NAME: String! DATABASE_URL: String! @@ -126,7 +128,18 @@ type Env { ORGANIZATION_LOGO: String } +type ValidateJWTTokenResponse { + is_valid: Boolean! +} + +type GenerateJWTKeysResponse { + secret: String + public_key: String + private_key: String +} + input UpdateEnvInput { + ACCESS_TOKEN_EXPIRY_TIME: String ADMIN_SECRET: String CUSTOM_ACCESS_TOKEN_SCRIPT: String OLD_ADMIN_SECRET: String @@ -281,6 +294,20 @@ input InviteMemberInput { redirect_uri: String } +input UpdateAccessInput { + user_id: String! +} + +input ValidateJWTTokenInput { + token_type: String! + token: String! + roles: [String!] +} + +input GenerateJWTKeysInput { + type: String! +} + type Mutation { signup(params: SignUpInput!): AuthResponse! login(params: LoginInput!): AuthResponse! @@ -300,12 +327,16 @@ type Mutation { _admin_logout: Response! _update_env(params: UpdateEnvInput!): Response! _invite_members(params: InviteMemberInput!): Response! + _revoke_access(param: UpdateAccessInput!): Response! + _enable_access(param: UpdateAccessInput!): Response! + _generate_jwt_keys(params: GenerateJWTKeysInput!): GenerateJWTKeysResponse! } type Query { meta: Meta! session(params: SessionQueryInput): AuthResponse! profile: User! + validate_jwt_token(params: ValidateJWTTokenInput!): ValidateJWTTokenResponse! # admin only apis _users(params: PaginatedInput): Users! _verification_requests(params: PaginatedInput): VerificationRequests! diff --git a/server/graph/schema.resolvers.go b/server/graph/schema.resolvers.go index e4f9275..51b2314 100644 --- a/server/graph/schema.resolvers.go +++ b/server/graph/schema.resolvers.go @@ -79,6 +79,18 @@ func (r *mutationResolver) InviteMembers(ctx context.Context, params model.Invit return resolvers.InviteMembersResolver(ctx, params) } +func (r *mutationResolver) RevokeAccess(ctx context.Context, param model.UpdateAccessInput) (*model.Response, error) { + return resolvers.RevokeAccessResolver(ctx, param) +} + +func (r *mutationResolver) EnableAccess(ctx context.Context, param model.UpdateAccessInput) (*model.Response, error) { + return resolvers.EnableAccessResolver(ctx, param) +} + +func (r *mutationResolver) GenerateJwtKeys(ctx context.Context, params model.GenerateJWTKeysInput) (*model.GenerateJWTKeysResponse, error) { + return resolvers.GenerateJWTKeysResolver(ctx, params) +} + func (r *queryResolver) Meta(ctx context.Context) (*model.Meta, error) { return resolvers.MetaResolver(ctx) } @@ -91,6 +103,10 @@ func (r *queryResolver) Profile(ctx context.Context) (*model.User, error) { return resolvers.ProfileResolver(ctx) } +func (r *queryResolver) ValidateJwtToken(ctx context.Context, params model.ValidateJWTTokenInput) (*model.ValidateJWTTokenResponse, error) { + return resolvers.ValidateJwtTokenResolver(ctx, params) +} + func (r *queryResolver) Users(ctx context.Context, params *model.PaginatedInput) (*model.Users, error) { return resolvers.UsersResolver(ctx, params) } diff --git a/server/handlers/authorize.go b/server/handlers/authorize.go index cfb913f..572ebae 100644 --- a/server/handlers/authorize.go +++ b/server/handlers/authorize.go @@ -1,10 +1,10 @@ package handlers import ( - "fmt" "net/http" "strconv" "strings" + "time" "github.com/authorizerdev/authorizer/server/constants" "github.com/authorizerdev/authorizer/server/cookie" @@ -51,8 +51,6 @@ func AuthorizeHandler() gin.HandlerFunc { gc.JSON(400, gin.H{"error": "invalid response mode"}) } - fmt.Println("=> redirect URI:", redirectURI) - fmt.Println("=> state:", state) if redirectURI == "" { redirectURI = "/app" } @@ -279,7 +277,11 @@ func AuthorizeHandler() gin.HandlerFunc { sessionstore.SetState(authToken.FingerPrintHash, authToken.FingerPrint+"@"+user.ID) sessionstore.SetState(authToken.AccessToken.Token, authToken.FingerPrint+"@"+user.ID) cookie.SetSession(gc, authToken.FingerPrintHash) - expiresIn := int64(1800) + + expiresIn := authToken.AccessToken.ExpiresAt - time.Now().Unix() + if expiresIn <= 0 { + expiresIn = 1 + } // used of query mode params := "access_token=" + authToken.AccessToken.Token + "&token_type=bearer&expires_in=" + strconv.FormatInt(expiresIn, 10) + "&state=" + state + "&id_token=" + authToken.IDToken.Token diff --git a/server/handlers/oauth_callback.go b/server/handlers/oauth_callback.go index 0c6ffd5..bfa4f00 100644 --- a/server/handlers/oauth_callback.go +++ b/server/handlers/oauth_callback.go @@ -95,9 +95,12 @@ func OAuthCallbackHandler() gin.HandlerFunc { user.EmailVerifiedAt = &now user, _ = db.Provider.AddUser(user) } else { + if user.RevokedTimestamp != nil { + c.JSON(400, gin.H{"error": "user access has been revoked"}) + } + // user exists in db, check if method was google // if not append google to existing signup method and save it - signupMethod := existingUser.SignupMethods if !strings.Contains(signupMethod, provider) { signupMethod = signupMethod + "," + provider @@ -154,7 +157,12 @@ func OAuthCallbackHandler() gin.HandlerFunc { if err != nil { c.JSON(500, gin.H{"error": err.Error()}) } - expiresIn := int64(1800) + + expiresIn := authToken.AccessToken.ExpiresAt - time.Now().Unix() + if expiresIn <= 0 { + expiresIn = 1 + } + params := "access_token=" + authToken.AccessToken.Token + "&token_type=bearer&expires_in=" + strconv.FormatInt(expiresIn, 10) + "&state=" + stateValue + "&id_token=" + authToken.IDToken.Token cookie.SetSession(c, authToken.FingerPrintHash) diff --git a/server/handlers/token.go b/server/handlers/token.go index 13abbb9..516aafe 100644 --- a/server/handlers/token.go +++ b/server/handlers/token.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "net/http" "strings" + "time" "github.com/authorizerdev/authorizer/server/constants" "github.com/authorizerdev/authorizer/server/cookie" @@ -174,7 +175,11 @@ func TokenHandler() gin.HandlerFunc { sessionstore.SetState(authToken.AccessToken.Token, authToken.FingerPrint+"@"+user.ID) cookie.SetSession(gc, authToken.FingerPrintHash) - expiresIn := int64(1800) + expiresIn := authToken.AccessToken.ExpiresAt - time.Now().Unix() + if expiresIn <= 0 { + expiresIn = 1 + } + res := map[string]interface{}{ "access_token": authToken.AccessToken.Token, "id_token": authToken.IDToken.Token, diff --git a/server/handlers/verify_email.go b/server/handlers/verify_email.go index 80fe6ad..6333620 100644 --- a/server/handlers/verify_email.go +++ b/server/handlers/verify_email.go @@ -82,7 +82,12 @@ func VerifyEmailHandler() gin.HandlerFunc { c.JSON(500, errorRes) return } - expiresIn := int64(1800) + + expiresIn := authToken.AccessToken.ExpiresAt - time.Now().Unix() + if expiresIn <= 0 { + expiresIn = 1 + } + params := "access_token=" + authToken.AccessToken.Token + "&token_type=bearer&expires_in=" + strconv.FormatInt(expiresIn, 10) + "&state=" + state + "&id_token=" + authToken.IDToken.Token cookie.SetSession(c, authToken.FingerPrintHash) diff --git a/server/middlewares/cors.go b/server/middlewares/cors.go index 0b85a62..ca06721 100644 --- a/server/middlewares/cors.go +++ b/server/middlewares/cors.go @@ -15,7 +15,7 @@ func CORSMiddleware() gin.HandlerFunc { } c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") - c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, X-authorizer-url") c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT") if c.Request.Method == "OPTIONS" { diff --git a/server/resolvers/enable_access.go b/server/resolvers/enable_access.go new file mode 100644 index 0000000..647cada --- /dev/null +++ b/server/resolvers/enable_access.go @@ -0,0 +1,44 @@ +package resolvers + +import ( + "context" + "fmt" + "log" + + "github.com/authorizerdev/authorizer/server/db" + "github.com/authorizerdev/authorizer/server/graph/model" + "github.com/authorizerdev/authorizer/server/token" + "github.com/authorizerdev/authorizer/server/utils" +) + +// EnableAccessResolver is a resolver for enabling user access +func EnableAccessResolver(ctx context.Context, params model.UpdateAccessInput) (*model.Response, error) { + gc, err := utils.GinContextFromContext(ctx) + var res *model.Response + if err != nil { + return res, err + } + + if !token.IsSuperAdmin(gc) { + return res, fmt.Errorf("unauthorized") + } + + user, err := db.Provider.GetUserByID(params.UserID) + if err != nil { + return res, err + } + + user.RevokedTimestamp = nil + + user, err = db.Provider.UpdateUser(user) + if err != nil { + log.Println("error updating user:", err) + return res, err + } + + res = &model.Response{ + Message: `user access enabled successfully`, + } + + return res, nil +} diff --git a/server/resolvers/env.go b/server/resolvers/env.go index d56ea89..dc7db8d 100644 --- a/server/resolvers/env.go +++ b/server/resolvers/env.go @@ -27,6 +27,7 @@ func EnvResolver(ctx context.Context) (*model.Env, error) { // get clone of store store := envstore.EnvStoreObj.GetEnvStoreClone() + accessTokenExpiryTime := store.StringEnv[constants.EnvKeyAccessTokenExpiryTime] adminSecret := store.StringEnv[constants.EnvKeyAdminSecret] clientID := store.StringEnv[constants.EnvKeyClientID] clientSecret := store.StringEnv[constants.EnvKeyClientSecret] @@ -66,7 +67,12 @@ func EnvResolver(ctx context.Context) (*model.Env, error) { organizationName := store.StringEnv[constants.EnvKeyOrganizationName] organizationLogo := store.StringEnv[constants.EnvKeyOrganizationLogo] + if accessTokenExpiryTime == "" { + accessTokenExpiryTime = "30m" + } + res = &model.Env{ + AccessTokenExpiryTime: &accessTokenExpiryTime, AdminSecret: &adminSecret, DatabaseName: databaseName, DatabaseURL: databaseURL, diff --git a/server/resolvers/generate_jwt_keys.go b/server/resolvers/generate_jwt_keys.go new file mode 100644 index 0000000..6c4c9e5 --- /dev/null +++ b/server/resolvers/generate_jwt_keys.go @@ -0,0 +1,60 @@ +package resolvers + +import ( + "context" + "fmt" + + "github.com/authorizerdev/authorizer/server/constants" + "github.com/authorizerdev/authorizer/server/crypto" + "github.com/authorizerdev/authorizer/server/envstore" + "github.com/authorizerdev/authorizer/server/graph/model" + "github.com/authorizerdev/authorizer/server/token" + "github.com/authorizerdev/authorizer/server/utils" +) + +// GenerateJWTKeysResolver mutation to generate new jwt keys +func GenerateJWTKeysResolver(ctx context.Context, params model.GenerateJWTKeysInput) (*model.GenerateJWTKeysResponse, error) { + gc, err := utils.GinContextFromContext(ctx) + if err != nil { + return nil, err + } + + if !token.IsSuperAdmin(gc) { + return nil, fmt.Errorf("unauthorized") + } + + clientID := envstore.EnvStoreObj.GetStringStoreEnvVariable(constants.EnvKeyClientID) + if crypto.IsHMACA(params.Type) { + secret, _, err := crypto.NewHMACKey(params.Type, clientID) + if err != nil { + return nil, err + } + return &model.GenerateJWTKeysResponse{ + Secret: &secret, + }, nil + } + + if crypto.IsRSA(params.Type) { + _, privateKey, publicKey, _, err := crypto.NewRSAKey(params.Type, clientID) + if err != nil { + return nil, err + } + return &model.GenerateJWTKeysResponse{ + PrivateKey: &privateKey, + PublicKey: &publicKey, + }, nil + } + + if crypto.IsECDSA(params.Type) { + _, privateKey, publicKey, _, err := crypto.NewECDSAKey(params.Type, clientID) + if err != nil { + return nil, err + } + return &model.GenerateJWTKeysResponse{ + PrivateKey: &privateKey, + PublicKey: &publicKey, + }, nil + } + + return nil, fmt.Errorf("invalid algorithm") +} diff --git a/server/resolvers/login.go b/server/resolvers/login.go index 355c77c..a93ca8d 100644 --- a/server/resolvers/login.go +++ b/server/resolvers/login.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "strings" + "time" "github.com/authorizerdev/authorizer/server/constants" "github.com/authorizerdev/authorizer/server/cookie" @@ -35,6 +36,10 @@ func LoginResolver(ctx context.Context, params model.LoginInput) (*model.AuthRes return res, fmt.Errorf(`user with this email not found`) } + if user.RevokedTimestamp != nil { + return res, fmt.Errorf(`user access has been revoked`) + } + if !strings.Contains(user.SignupMethods, constants.SignupMethodBasicAuth) { return res, fmt.Errorf(`user has not signed up email & password`) } @@ -69,7 +74,11 @@ func LoginResolver(ctx context.Context, params model.LoginInput) (*model.AuthRes return res, err } - expiresIn := int64(1800) + expiresIn := authToken.AccessToken.ExpiresAt - time.Now().Unix() + if expiresIn <= 0 { + expiresIn = 1 + } + res = &model.AuthResponse{ Message: `Logged in successfully`, AccessToken: &authToken.AccessToken.Token, diff --git a/server/resolvers/magic_link_login.go b/server/resolvers/magic_link_login.go index b69ea91..1c9d0bc 100644 --- a/server/resolvers/magic_link_login.go +++ b/server/resolvers/magic_link_login.go @@ -70,6 +70,10 @@ func MagicLinkLoginResolver(ctx context.Context, params model.MagicLinkLoginInpu // 2. user has not signed up for one of the available role but trying to signup. // Need to modify roles in this case + if user.RevokedTimestamp != nil { + return res, fmt.Errorf(`user access has been revoked`) + } + // find the unassigned roles if len(params.Roles) <= 0 { inputRoles = envstore.EnvStoreObj.GetSliceStoreEnvVariable(constants.EnvKeyDefaultRoles) diff --git a/server/resolvers/revoke_access.go b/server/resolvers/revoke_access.go new file mode 100644 index 0000000..a470be8 --- /dev/null +++ b/server/resolvers/revoke_access.go @@ -0,0 +1,49 @@ +package resolvers + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/authorizerdev/authorizer/server/db" + "github.com/authorizerdev/authorizer/server/graph/model" + "github.com/authorizerdev/authorizer/server/sessionstore" + "github.com/authorizerdev/authorizer/server/token" + "github.com/authorizerdev/authorizer/server/utils" +) + +// RevokeAccessResolver is a resolver for revoking user access +func RevokeAccessResolver(ctx context.Context, params model.UpdateAccessInput) (*model.Response, error) { + gc, err := utils.GinContextFromContext(ctx) + var res *model.Response + if err != nil { + return res, err + } + + if !token.IsSuperAdmin(gc) { + return res, fmt.Errorf("unauthorized") + } + + user, err := db.Provider.GetUserByID(params.UserID) + if err != nil { + return res, err + } + + now := time.Now().Unix() + user.RevokedTimestamp = &now + + user, err = db.Provider.UpdateUser(user) + if err != nil { + log.Println("error updating user:", err) + return res, err + } + + go sessionstore.DeleteAllUserSession(fmt.Sprintf("%x", user.ID)) + + res = &model.Response{ + Message: `user access revoked successfully`, + } + + return res, nil +} diff --git a/server/resolvers/session.go b/server/resolvers/session.go index e68fe07..22e7171 100644 --- a/server/resolvers/session.go +++ b/server/resolvers/session.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log" + "time" "github.com/authorizerdev/authorizer/server/cookie" "github.com/authorizerdev/authorizer/server/db" @@ -73,7 +74,11 @@ func SessionResolver(ctx context.Context, params *model.SessionQueryInput) (*mod sessionstore.SetState(authToken.AccessToken.Token, authToken.FingerPrint+"@"+user.ID) cookie.SetSession(gc, authToken.FingerPrintHash) - expiresIn := int64(1800) + expiresIn := authToken.AccessToken.ExpiresAt - time.Now().Unix() + if expiresIn <= 0 { + expiresIn = 1 + } + res = &model.AuthResponse{ Message: `Session token refreshed`, AccessToken: &authToken.AccessToken.Token, diff --git a/server/resolvers/signup.go b/server/resolvers/signup.go index 76d7175..317416e 100644 --- a/server/resolvers/signup.go +++ b/server/resolvers/signup.go @@ -176,7 +176,10 @@ func SignupResolver(ctx context.Context, params model.SignUpInput) (*model.AuthR cookie.SetSession(gc, authToken.FingerPrintHash) go utils.SaveSessionInDB(gc, user.ID) - expiresIn := int64(1800) + expiresIn := authToken.AccessToken.ExpiresAt - time.Now().Unix() + if expiresIn <= 0 { + expiresIn = 1 + } res = &model.AuthResponse{ Message: `Signed up successfully.`, diff --git a/server/resolvers/update_env.go b/server/resolvers/update_env.go index 0f428bb..8da29d7 100644 --- a/server/resolvers/update_env.go +++ b/server/resolvers/update_env.go @@ -53,11 +53,19 @@ func UpdateEnvResolver(ctx context.Context, params model.UpdateEnvInput) (*model } if isJWTUpdated { + // use to reset when type is changed from rsa, edsa -> hmac or vice a versa + defaultSecret := "" + defaultPublicKey := "" + defaultPrivateKey := "" // check if jwt secret is provided if crypto.IsHMACA(algo) { if params.JwtSecret == nil { return res, fmt.Errorf("jwt secret is required for HMAC algorithm") } + + // reset public key and private key + params.JwtPrivateKey = &defaultPrivateKey + params.JwtPublicKey = &defaultPublicKey } if crypto.IsRSA(algo) { @@ -65,6 +73,8 @@ func UpdateEnvResolver(ctx context.Context, params model.UpdateEnvInput) (*model return res, fmt.Errorf("jwt private and public key is required for RSA (PKCS1) / ECDSA algorithm") } + // reset the jwt secret + params.JwtSecret = &defaultSecret _, err = crypto.ParseRsaPrivateKeyFromPemStr(*params.JwtPrivateKey) if err != nil { return res, err @@ -81,6 +91,8 @@ func UpdateEnvResolver(ctx context.Context, params model.UpdateEnvInput) (*model return res, fmt.Errorf("jwt private and public key is required for RSA (PKCS1) / ECDSA algorithm") } + // reset the jwt secret + params.JwtSecret = &defaultSecret _, err = crypto.ParseEcdsaPrivateKeyFromPemStr(*params.JwtPrivateKey) if err != nil { return res, err diff --git a/server/resolvers/validate_jwt_token.go b/server/resolvers/validate_jwt_token.go new file mode 100644 index 0000000..ce1c84c --- /dev/null +++ b/server/resolvers/validate_jwt_token.go @@ -0,0 +1,86 @@ +package resolvers + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/authorizerdev/authorizer/server/graph/model" + "github.com/authorizerdev/authorizer/server/sessionstore" + "github.com/authorizerdev/authorizer/server/token" + "github.com/authorizerdev/authorizer/server/utils" + "github.com/golang-jwt/jwt" +) + +// ValidateJwtTokenResolver is used to validate a jwt token without its rotation +// this can be used at API level (backend) +// it can validate: +// access_token +// id_token +// refresh_token +func ValidateJwtTokenResolver(ctx context.Context, params model.ValidateJWTTokenInput) (*model.ValidateJWTTokenResponse, error) { + gc, err := utils.GinContextFromContext(ctx) + if err != nil { + return nil, err + } + + tokenType := params.TokenType + if tokenType != "access_token" && tokenType != "refresh_token" && tokenType != "id_token" { + return nil, errors.New("invalid token type") + } + + userID := "" + nonce := "" + // access_token and refresh_token should be validated from session store as well + if tokenType == "access_token" || tokenType == "refresh_token" { + savedSession := sessionstore.GetState(params.Token) + if savedSession == "" { + return &model.ValidateJWTTokenResponse{ + IsValid: false, + }, nil + } + savedSessionSplit := strings.Split(savedSession, "@") + nonce = savedSessionSplit[0] + userID = savedSessionSplit[1] + } + + hostname := utils.GetHost(gc) + var claimRoles []string + var claims jwt.MapClaims + + // we cannot validate sub and nonce in case of id_token as that token is not persisted in session store + if userID != "" && nonce != "" { + claims, err = token.ParseJWTToken(params.Token, hostname, nonce, userID) + if err != nil { + return &model.ValidateJWTTokenResponse{ + IsValid: false, + }, nil + } + } else { + claims, err = token.ParseJWTTokenWithoutNonce(params.Token, hostname) + if err != nil { + return &model.ValidateJWTTokenResponse{ + IsValid: false, + }, nil + } + + } + + claimRolesInterface := claims["roles"] + roleSlice := utils.ConvertInterfaceToSlice(claimRolesInterface) + for _, v := range roleSlice { + claimRoles = append(claimRoles, v.(string)) + } + + if params.Roles != nil && len(params.Roles) > 0 { + for _, v := range params.Roles { + if !utils.StringSliceContains(claimRoles, v) { + return nil, fmt.Errorf(`unauthorized`) + } + } + } + return &model.ValidateJWTTokenResponse{ + IsValid: true, + }, nil +} diff --git a/server/resolvers/verify_email.go b/server/resolvers/verify_email.go index fe13c9a..65e8494 100644 --- a/server/resolvers/verify_email.go +++ b/server/resolvers/verify_email.go @@ -64,7 +64,11 @@ func VerifyEmailResolver(ctx context.Context, params model.VerifyEmailInput) (*m cookie.SetSession(gc, authToken.FingerPrintHash) go utils.SaveSessionInDB(gc, user.ID) - expiresIn := int64(1800) + expiresIn := authToken.AccessToken.ExpiresAt - time.Now().Unix() + if expiresIn <= 0 { + expiresIn = 1 + } + res = &model.AuthResponse{ Message: `Email verified successfully.`, AccessToken: &authToken.AccessToken.Token, diff --git a/server/test/enable_access_test.go b/server/test/enable_access_test.go new file mode 100644 index 0000000..c54f91b --- /dev/null +++ b/server/test/enable_access_test.go @@ -0,0 +1,57 @@ +package test + +import ( + "fmt" + "testing" + + "github.com/authorizerdev/authorizer/server/constants" + "github.com/authorizerdev/authorizer/server/crypto" + "github.com/authorizerdev/authorizer/server/db" + "github.com/authorizerdev/authorizer/server/envstore" + "github.com/authorizerdev/authorizer/server/graph/model" + "github.com/authorizerdev/authorizer/server/resolvers" + "github.com/stretchr/testify/assert" +) + +func enableAccessTest(t *testing.T, s TestSetup) { + t.Helper() + t.Run(`should revoke access`, func(t *testing.T) { + req, ctx := createContext(s) + email := "revoke_access." + s.TestInfo.Email + _, err := resolvers.MagicLinkLoginResolver(ctx, model.MagicLinkLoginInput{ + Email: email, + }) + assert.NoError(t, err) + verificationRequest, err := db.Provider.GetVerificationRequestByEmail(email, constants.VerificationTypeMagicLinkLogin) + verifyRes, err := resolvers.VerifyEmailResolver(ctx, model.VerifyEmailInput{ + Token: verificationRequest.Token, + }) + assert.NoError(t, err) + assert.NotNil(t, verifyRes.AccessToken) + + h, err := crypto.EncryptPassword(envstore.EnvStoreObj.GetStringStoreEnvVariable(constants.EnvKeyAdminSecret)) + assert.Nil(t, err) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", envstore.EnvStoreObj.GetStringStoreEnvVariable(constants.EnvKeyAdminCookieName), h)) + + res, err := resolvers.RevokeAccessResolver(ctx, model.UpdateAccessInput{ + UserID: verifyRes.User.ID, + }) + assert.NoError(t, err) + assert.NotEmpty(t, res.Message) + + res, err = resolvers.EnableAccessResolver(ctx, model.UpdateAccessInput{ + UserID: verifyRes.User.ID, + }) + assert.NoError(t, err) + assert.NotEmpty(t, res.Message) + + // it should allow login with revoked access + res, err = resolvers.MagicLinkLoginResolver(ctx, model.MagicLinkLoginInput{ + Email: email, + }) + assert.Nil(t, err) + assert.NotEmpty(t, res.Message) + + cleanData(email) + }) +} diff --git a/server/test/generate_jwt_keys_test.go b/server/test/generate_jwt_keys_test.go new file mode 100644 index 0000000..b9acb76 --- /dev/null +++ b/server/test/generate_jwt_keys_test.go @@ -0,0 +1,62 @@ +package test + +import ( + "fmt" + "testing" + + "github.com/authorizerdev/authorizer/server/constants" + "github.com/authorizerdev/authorizer/server/crypto" + "github.com/authorizerdev/authorizer/server/envstore" + "github.com/authorizerdev/authorizer/server/graph/model" + "github.com/authorizerdev/authorizer/server/resolvers" + "github.com/stretchr/testify/assert" +) + +func generateJWTkeyTest(t *testing.T, s TestSetup) { + t.Helper() + req, ctx := createContext(s) + t.Run(`generate_jwt_keys`, func(t *testing.T) { + t.Run(`should throw unauthorized`, func(t *testing.T) { + res, err := resolvers.GenerateJWTKeysResolver(ctx, model.GenerateJWTKeysInput{ + Type: "HS256", + }) + assert.Error(t, err) + assert.Nil(t, res) + }) + t.Run(`should throw invalid`, func(t *testing.T) { + res, err := resolvers.GenerateJWTKeysResolver(ctx, model.GenerateJWTKeysInput{ + Type: "test", + }) + assert.Error(t, err) + assert.Nil(t, res) + }) + h, err := crypto.EncryptPassword(envstore.EnvStoreObj.GetStringStoreEnvVariable(constants.EnvKeyAdminSecret)) + assert.Nil(t, err) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", envstore.EnvStoreObj.GetStringStoreEnvVariable(constants.EnvKeyAdminCookieName), h)) + t.Run(`should generate HS256 secret`, func(t *testing.T) { + res, err := resolvers.GenerateJWTKeysResolver(ctx, model.GenerateJWTKeysInput{ + Type: "HS256", + }) + assert.NoError(t, err) + assert.NotEmpty(t, res.Secret) + }) + + t.Run(`should generate RS256 secret`, func(t *testing.T) { + res, err := resolvers.GenerateJWTKeysResolver(ctx, model.GenerateJWTKeysInput{ + Type: "RS256", + }) + assert.NoError(t, err) + assert.NotEmpty(t, res.PrivateKey) + assert.NotEmpty(t, res.PublicKey) + }) + + t.Run(`should generate ES256 secret`, func(t *testing.T) { + res, err := resolvers.GenerateJWTKeysResolver(ctx, model.GenerateJWTKeysInput{ + Type: "ES256", + }) + assert.NoError(t, err) + assert.NotEmpty(t, res.PrivateKey) + assert.NotEmpty(t, res.PublicKey) + }) + }) +} diff --git a/server/test/resolvers_test.go b/server/test/resolvers_test.go index 7e0c41d..40812b1 100644 --- a/server/test/resolvers_test.go +++ b/server/test/resolvers_test.go @@ -48,6 +48,9 @@ func TestResolvers(t *testing.T) { adminSessionTests(t, s) updateEnvTests(t, s) envTests(t, s) + revokeAccessTest(t, s) + enableAccessTest(t, s) + generateJWTkeyTest(t, s) // user tests loginTests(t, s) @@ -63,6 +66,7 @@ func TestResolvers(t *testing.T) { logoutTests(t, s) metaTests(t, s) inviteUserTest(t, s) + validateJwtTokenTest(t, s) }) } } diff --git a/server/test/revoke_access_test.go b/server/test/revoke_access_test.go new file mode 100644 index 0000000..5317721 --- /dev/null +++ b/server/test/revoke_access_test.go @@ -0,0 +1,54 @@ +package test + +import ( + "fmt" + "testing" + + "github.com/authorizerdev/authorizer/server/constants" + "github.com/authorizerdev/authorizer/server/crypto" + "github.com/authorizerdev/authorizer/server/db" + "github.com/authorizerdev/authorizer/server/envstore" + "github.com/authorizerdev/authorizer/server/graph/model" + "github.com/authorizerdev/authorizer/server/resolvers" + "github.com/stretchr/testify/assert" +) + +func revokeAccessTest(t *testing.T, s TestSetup) { + t.Helper() + t.Run(`should revoke access`, func(t *testing.T) { + req, ctx := createContext(s) + email := "revoke_access." + s.TestInfo.Email + _, err := resolvers.MagicLinkLoginResolver(ctx, model.MagicLinkLoginInput{ + Email: email, + }) + assert.NoError(t, err) + verificationRequest, err := db.Provider.GetVerificationRequestByEmail(email, constants.VerificationTypeMagicLinkLogin) + verifyRes, err := resolvers.VerifyEmailResolver(ctx, model.VerifyEmailInput{ + Token: verificationRequest.Token, + }) + assert.NoError(t, err) + assert.NotNil(t, verifyRes.AccessToken) + + res, err := resolvers.RevokeAccessResolver(ctx, model.UpdateAccessInput{ + UserID: verifyRes.User.ID, + }) + assert.Error(t, err) + + h, err := crypto.EncryptPassword(envstore.EnvStoreObj.GetStringStoreEnvVariable(constants.EnvKeyAdminSecret)) + assert.Nil(t, err) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", envstore.EnvStoreObj.GetStringStoreEnvVariable(constants.EnvKeyAdminCookieName), h)) + + res, err = resolvers.RevokeAccessResolver(ctx, model.UpdateAccessInput{ + UserID: verifyRes.User.ID, + }) + assert.NoError(t, err) + assert.NotEmpty(t, res.Message) + + // it should not allow login with revoked access + _, err = resolvers.MagicLinkLoginResolver(ctx, model.MagicLinkLoginInput{ + Email: email, + }) + assert.Error(t, err) + cleanData(email) + }) +} diff --git a/server/test/validate_jwt_token_test.go b/server/test/validate_jwt_token_test.go new file mode 100644 index 0000000..5bb4268 --- /dev/null +++ b/server/test/validate_jwt_token_test.go @@ -0,0 +1,90 @@ +package test + +import ( + "testing" + "time" + + "github.com/authorizerdev/authorizer/server/db/models" + "github.com/authorizerdev/authorizer/server/graph/model" + "github.com/authorizerdev/authorizer/server/resolvers" + "github.com/authorizerdev/authorizer/server/sessionstore" + "github.com/authorizerdev/authorizer/server/token" + "github.com/authorizerdev/authorizer/server/utils" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func validateJwtTokenTest(t *testing.T, s TestSetup) { + t.Helper() + _, ctx := createContext(s) + t.Run(`validate params`, func(t *testing.T) { + res, err := resolvers.ValidateJwtTokenResolver(ctx, model.ValidateJWTTokenInput{ + TokenType: "access_token", + Token: "", + }) + assert.False(t, res.IsValid) + res, err = resolvers.ValidateJwtTokenResolver(ctx, model.ValidateJWTTokenInput{ + TokenType: "access_token", + Token: "invalid", + }) + assert.False(t, res.IsValid) + _, err = resolvers.ValidateJwtTokenResolver(ctx, model.ValidateJWTTokenInput{ + TokenType: "access_token_invalid", + Token: "invalid@invalid", + }) + assert.Error(t, err, "invalid token") + }) + + scope := []string{"openid", "email", "profile", "offline_access"} + user := models.User{ + ID: uuid.New().String(), + Email: "jwt_test_" + s.TestInfo.Email, + Roles: "user", + UpdatedAt: time.Now().Unix(), + CreatedAt: time.Now().Unix(), + } + + roles := []string{"user"} + gc, err := utils.GinContextFromContext(ctx) + assert.NoError(t, err) + authToken, err := token.CreateAuthToken(gc, user, roles, scope) + sessionstore.SetState(authToken.AccessToken.Token, authToken.FingerPrint+"@"+user.ID) + sessionstore.SetState(authToken.RefreshToken.Token, authToken.FingerPrint+"@"+user.ID) + + t.Run(`should validate the access token`, func(t *testing.T) { + res, err := resolvers.ValidateJwtTokenResolver(ctx, model.ValidateJWTTokenInput{ + TokenType: "access_token", + Token: authToken.AccessToken.Token, + Roles: []string{"user"}, + }) + + assert.NoError(t, err) + assert.True(t, res.IsValid) + + res, err = resolvers.ValidateJwtTokenResolver(ctx, model.ValidateJWTTokenInput{ + TokenType: "access_token", + Token: authToken.AccessToken.Token, + Roles: []string{"invalid_role"}, + }) + + assert.Error(t, err) + }) + + t.Run(`should validate the refresh token`, func(t *testing.T) { + res, err := resolvers.ValidateJwtTokenResolver(ctx, model.ValidateJWTTokenInput{ + TokenType: "refresh_token", + Token: authToken.RefreshToken.Token, + }) + assert.NoError(t, err) + assert.True(t, res.IsValid) + }) + + t.Run(`should validate the id token`, func(t *testing.T) { + res, err := resolvers.ValidateJwtTokenResolver(ctx, model.ValidateJWTTokenInput{ + TokenType: "id_token", + Token: authToken.IDToken.Token, + }) + assert.NoError(t, err) + assert.True(t, res.IsValid) + }) +} diff --git a/server/token/auth_token.go b/server/token/auth_token.go index 350da17..9608809 100644 --- a/server/token/auth_token.go +++ b/server/token/auth_token.go @@ -130,7 +130,11 @@ func CreateRefreshToken(user models.User, roles, scopes []string, hostname, nonc // CreateAccessToken util to create JWT token, based on // user information, roles config and CUSTOM_ACCESS_TOKEN_SCRIPT func CreateAccessToken(user models.User, roles, scopes []string, hostName, nonce string) (string, int64, error) { - expiryBound := time.Minute * 30 + expiryBound, err := utils.ParseDurationInSeconds(envstore.EnvStoreObj.GetStringStoreEnvVariable(constants.EnvKeyAccessTokenExpiryTime)) + if err != nil { + expiryBound = time.Minute * 30 + } + expiresAt := time.Now().Add(expiryBound).Unix() customClaims := jwt.MapClaims{ @@ -161,7 +165,12 @@ func GetAccessToken(gc *gin.Context) (string, error) { return "", fmt.Errorf(`unauthorized`) } - if !strings.HasPrefix(auth, "Bearer ") { + authSplit := strings.Split(auth, " ") + if len(authSplit) != 2 { + return "", fmt.Errorf(`unauthorized`) + } + + if strings.ToLower(authSplit[0]) != "bearer" { return "", fmt.Errorf(`not a bearer token`) } @@ -277,7 +286,11 @@ func ValidateBrowserSession(gc *gin.Context, encryptedSession string) (*SessionD // CreateIDToken util to create JWT token, based on // user information, roles config and CUSTOM_ACCESS_TOKEN_SCRIPT func CreateIDToken(user models.User, roles []string, hostname, nonce string) (string, int64, error) { - expiryBound := time.Minute * 30 + expiryBound, err := utils.ParseDurationInSeconds(envstore.EnvStoreObj.GetStringStoreEnvVariable(constants.EnvKeyAccessTokenExpiryTime)) + if err != nil { + expiryBound = time.Minute * 30 + } + expiresAt := time.Now().Add(expiryBound).Unix() resUser := user.AsAPIUser() @@ -350,7 +363,12 @@ func GetIDToken(gc *gin.Context) (string, error) { return "", fmt.Errorf(`unauthorized`) } - if !strings.HasPrefix(auth, "Bearer ") { + authSplit := strings.Split(auth, " ") + if len(authSplit) != 2 { + return "", fmt.Errorf(`unauthorized`) + } + + if strings.ToLower(authSplit[0]) != "bearer" { return "", fmt.Errorf(`not a bearer token`) } diff --git a/server/token/jwt.go b/server/token/jwt.go index 90f6333..0b87c09 100644 --- a/server/token/jwt.go +++ b/server/token/jwt.go @@ -105,3 +105,59 @@ func ParseJWTToken(token, hostname, nonce, subject string) (jwt.MapClaims, error return claims, nil } + +// ParseJWTTokenWithoutNonce common util to parse jwt token without nonce +// used to validate ID token as it is not persisted in store +func ParseJWTTokenWithoutNonce(token, hostname string) (jwt.MapClaims, error) { + jwtType := envstore.EnvStoreObj.GetStringStoreEnvVariable(constants.EnvKeyJwtType) + signingMethod := jwt.GetSigningMethod(jwtType) + + var err error + var claims jwt.MapClaims + + switch signingMethod { + case jwt.SigningMethodHS256, jwt.SigningMethodHS384, jwt.SigningMethodHS512: + _, err = jwt.ParseWithClaims(token, &claims, func(token *jwt.Token) (interface{}, error) { + return []byte(envstore.EnvStoreObj.GetStringStoreEnvVariable(constants.EnvKeyJwtSecret)), nil + }) + case jwt.SigningMethodRS256, jwt.SigningMethodRS384, jwt.SigningMethodRS512: + _, err = jwt.ParseWithClaims(token, &claims, func(token *jwt.Token) (interface{}, error) { + key, err := crypto.ParseRsaPublicKeyFromPemStr(envstore.EnvStoreObj.GetStringStoreEnvVariable(constants.EnvKeyJwtPublicKey)) + if err != nil { + return nil, err + } + return key, nil + }) + case jwt.SigningMethodES256, jwt.SigningMethodES384, jwt.SigningMethodES512: + _, err = jwt.ParseWithClaims(token, &claims, func(token *jwt.Token) (interface{}, error) { + key, err := crypto.ParseEcdsaPublicKeyFromPemStr(envstore.EnvStoreObj.GetStringStoreEnvVariable(constants.EnvKeyJwtPublicKey)) + if err != nil { + return nil, err + } + return key, nil + }) + default: + err = errors.New("unsupported signing method") + } + if err != nil { + return claims, err + } + + // claim parses exp & iat into float 64 with e^10, + // but we expect it to be int64 + // hence we need to assert interface and convert to int64 + intExp := int64(claims["exp"].(float64)) + intIat := int64(claims["iat"].(float64)) + claims["exp"] = intExp + claims["iat"] = intIat + + if claims["aud"] != envstore.EnvStoreObj.GetStringStoreEnvVariable(constants.EnvKeyClientID) { + return claims, errors.New("invalid audience") + } + + if claims["iss"] != hostname { + return claims, errors.New("invalid issuer") + } + + return claims, nil +} diff --git a/server/utils/common.go b/server/utils/common.go index d4a8d51..6835806 100644 --- a/server/utils/common.go +++ b/server/utils/common.go @@ -2,6 +2,7 @@ package utils import ( "log" + "reflect" "github.com/authorizerdev/authorizer/server/db" "github.com/authorizerdev/authorizer/server/db/models" @@ -47,3 +48,24 @@ func RemoveDuplicateString(strSlice []string) []string { } return list } + +// ConvertInterfaceToSlice to convert interface to slice interface +func ConvertInterfaceToSlice(slice interface{}) []interface{} { + s := reflect.ValueOf(slice) + if s.Kind() != reflect.Slice { + return nil + } + + // Keep the distinction between nil and empty slice input + if s.IsNil() { + return nil + } + + ret := make([]interface{}, s.Len()) + + for i := 0; i < s.Len(); i++ { + ret[i] = s.Index(i).Interface() + } + + return ret +} diff --git a/server/utils/parser.go b/server/utils/parser.go new file mode 100644 index 0000000..1b037c0 --- /dev/null +++ b/server/utils/parser.go @@ -0,0 +1,21 @@ +package utils + +import ( + "errors" + "time" +) + +// ParseDurationInSeconds parses input s, removes ms/us/ns and returns result duration +func ParseDurationInSeconds(s string) (time.Duration, error) { + d, err := time.ParseDuration(s) + if err != nil { + return 0, err + } + + d = d.Truncate(time.Second) + if d <= 0 { + return 0, errors.New(`duration must be greater than 0s`) + } + + return d, nil +} diff --git a/server/utils/urls.go b/server/utils/urls.go index 390cfbd..f97582b 100644 --- a/server/utils/urls.go +++ b/server/utils/urls.go @@ -10,7 +10,20 @@ import ( ) // GetHost returns hostname from request context +// if X-Authorizer-URL header is set it is given highest priority +// if EnvKeyAuthorizerURL is set it is given second highest priority. +// if above 2 are not set the requesting host name is used func GetHost(c *gin.Context) string { + authorizerURL := c.Request.Header.Get("X-Authorizer-URL") + if authorizerURL != "" { + return authorizerURL + } + + authorizerURL = envstore.EnvStoreObj.GetStringStoreEnvVariable(constants.EnvKeyAuthorizerURL) + if authorizerURL != "" { + return authorizerURL + } + scheme := c.Request.Header.Get("X-Forwarded-Proto") if scheme != "https" { scheme = "http"