Compare commits

..

28 Commits
1.0.1 ... 1.1.4

Author SHA1 Message Date
Lakhan Samani
21b70e4b26 Merge pull request #230 from authorizerdev/fix/github-oauth-scopes
fix: scope for github auth
2022-09-14 11:46:46 +05:30
Lakhan Samani
993693884d fix: scope for github auth 2022-09-14 11:45:38 +05:30
Lakhan Samani
ed849fa6f6 Merge branch 'main' of https://github.com/authorizerdev/authorizer 2022-09-14 10:44:09 +05:30
Lakhan Samani
aec1f5df53 fix: github endpoint to get user emails 2022-09-14 10:44:01 +05:30
Lakhan Samani
45b4c41bca Merge pull request #228 from Deep-Codes/main 2022-09-10 11:40:11 +05:30
Deepankar
63d486821e fix: lint 2022-09-10 11:39:01 +05:30
Deep-Codes
4b56afdc98 fix(type): __authorizer__ on window 2022-09-10 11:23:20 +05:30
Lakhan Samani
6455ff956a fix: remove varible log 2022-09-10 10:52:56 +05:30
Lakhan Samani
3898e43fff feat: add button to jwt config as json 2022-09-10 10:50:15 +05:30
Lakhan Samani
2c305e5bde Update README.md 2022-09-09 10:24:30 +05:30
Lakhan Samani
b8fd08e576 Update README.md 2022-09-09 09:29:27 +05:30
Lakhan Samani
6dafa45051 fix: invalid login message
Resolves #224
2022-09-03 21:48:33 +05:30
Lakhan Samani
ead3514113 chore: update railway template 2022-08-31 13:09:00 +05:30
Lakhan Samani
75a413e5f2 Merge branch 'main' of https://github.com/authorizerdev/authorizer 2022-08-31 11:02:50 +05:30
Lakhan Samani
91bf0e2478 fix: use replace all 2022-08-31 11:02:46 +05:30
Lakhan Samani
7a1305cf96 Merge pull request #222 from Deep-Codes/main 2022-08-31 07:04:20 +05:30
Deep-Codes
ff5a6ec301 feat(server): add log to show PORT 2022-08-30 23:35:43 +05:30
Lakhan Samani
b7b97b4f8d Merge pull request #221 from Deep-Codes/main
fix(dashboard): users table overflow
2022-08-30 22:38:49 +05:30
Deep-Codes
d9bc989c74 fix(dashboard): users table overflow 2022-08-30 21:56:28 +05:30
Lakhan Samani
d1f80d4088 feat: add support for twitter login 2022-08-29 08:37:53 +05:30
Lakhan Samani
4b299f0da2 fix: log 2022-08-29 08:19:11 +05:30
Lakhan Samani
ed8006db4c Merge branch 'main' of https://github.com/authorizerdev/authorizer 2022-08-29 08:18:42 +05:30
Lakhan Samani
97f6c7d50a fix: authorize endpoint setting user session 2022-08-29 08:18:20 +05:30
Lakhan Samani
5e3f68a180 Merge pull request #216 from szczepad/feat/twitter-login
Feat/twitter login
2022-08-24 08:53:52 +05:30
szczepad
f73d1fc588 feat: Adds login via twitter 2022-08-22 09:25:10 +02:00
szczepad
aa232de426 fix: Uses whitespace as seperator for oauth scopes in state-string
This is necessary, as the previous delimiter (,) was being redacted
after a redirect. This resulted in the scopes not being correctly
parseable and the state not being fetched correctly after the
oauth-callback
2022-08-22 09:25:10 +02:00
Lakhan Samani
34ce754ef6 feat: bootstrap twitter login config 2022-08-22 09:03:29 +02:00
Lakhan Samani
5f385b2016 fix: remove unused file 2022-08-18 07:21:50 +05:30
35 changed files with 911 additions and 449 deletions

View File

@@ -7,7 +7,7 @@
Authorizer
</h1>
**Authorizer** is an open-source authentication and authorization solution for your applications. Bring your database and have complete control over the user information. You can self-host authorizer instances and connect to any database (Currently supports [Postgres](https://www.postgresql.org/), [MySQL](https://www.mysql.com/), [SQLite](https://www.sqlite.org/index.html), [SQLServer](https://www.microsoft.com/en-us/sql-server/), [MongoDB](https://mongodb.com/), [ArangoDB](https://www.arangodb.com/)).
**Authorizer** is an open-source authentication and authorization solution for your applications. Bring your database and have complete control over the user information. You can self-host authorizer instances and connect to any database (Currently supports 11+ databases including [Postgres](https://www.postgresql.org/), [MySQL](https://www.mysql.com/), [SQLite](https://www.sqlite.org/index.html), [SQLServer](https://www.microsoft.com/en-us/sql-server/), [YugaByte](https://www.yugabyte.com/), [MariaDB](https://mariadb.org/), [PlanetScale](https://planetscale.com/), [CassandraDB](https://cassandra.apache.org/_/index.html), [ScyllaDB](https://www.scylladb.com/), [MongoDB](https://mongodb.com/), [ArangoDB](https://www.arangodb.com/)).
## Table of contents
@@ -19,7 +19,7 @@
# Introduction
<img src="https://github.com/authorizerdev/authorizer/blob/main/assets/authorizer-architecture.png" style="height:20em"/>
<img src="https://docs.authorizer.dev/images/authorizer-arch.png" style="height:20em"/>
#### We offer the following functionality
@@ -65,11 +65,11 @@
Deploy production ready Authorizer instance using one click deployment options available below
| **Infra provider** | **One-click link** | **Additional information** |
| :----------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------: |
| Railway.app | <a href="https://railway.app/new/template?template=https://github.com/authorizerdev/authorizer-railway&amp;plugins=postgresql,redis"><img src="https://railway.app/button.svg" style="height: 44px" alt="Deploy on Railway"></a> | [docs](https://docs.authorizer.dev/deployment/railway) |
| Heroku | <a href="https://heroku.com/deploy?template=https://github.com/authorizerdev/authorizer-heroku"><img src="https://www.herokucdn.com/deploy/button.svg" alt="Deploy to Heroku" style="height: 44px;"></a> | [docs](https://docs.authorizer.dev/deployment/heroku) |
| Render | [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/authorizerdev/authorizer-render) | [docs](https://docs.authorizer.dev/deployment/render) |
| **Infra provider** | **One-click link** | **Additional information** |
| :----------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------: |
| Railway.app | <a href="https://railway.app/new/template/nwXp1C?referralCode=FEF4uT"><img src="https://railway.app/button.svg" style="height: 44px" alt="Deploy on Railway"></a> | [docs](https://docs.authorizer.dev/deployment/railway) |
| Heroku | <a href="https://heroku.com/deploy?template=https://github.com/authorizerdev/authorizer-heroku"><img src="https://www.herokucdn.com/deploy/button.svg" alt="Deploy to Heroku" style="height: 44px;"></a> | [docs](https://docs.authorizer.dev/deployment/heroku) |
| Render | [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/authorizerdev/authorizer-render) | [docs](https://docs.authorizer.dev/deployment/render) |
### Deploy Authorizer Using Source Code

30
app/package-lock.json generated
View File

@@ -9,7 +9,7 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@authorizerdev/authorizer-react": "^1.0.0",
"@authorizerdev/authorizer-react": "^1.1.0",
"@types/react": "^17.0.15",
"@types/react-dom": "^17.0.9",
"esbuild": "^0.12.17",
@@ -26,9 +26,9 @@
}
},
"node_modules/@authorizerdev/authorizer-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-1.0.0.tgz",
"integrity": "sha512-TtXA8y06CIZ5f+nk1tgPiSpGR9neCkGHtmKLqGWjWPdObDfekRU5qMtpC2S2uEljAI53mnueLZKmbd9hrReTag==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-1.1.0.tgz",
"integrity": "sha512-MdEw1SjhIm7pXq20AscHSbnAta2PC3w7GNBY52/OzmlBXUGH3ooUQX/aszbYOse3FlhapcrGrRvg4sNM7faGAg==",
"dependencies": {
"cross-fetch": "^3.1.5"
},
@@ -37,11 +37,11 @@
}
},
"node_modules/@authorizerdev/authorizer-react": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-react/-/authorizer-react-1.0.0.tgz",
"integrity": "sha512-lXckUe46LTcH+hFxIxPEewQR1/ktd2awoqZsMZZaa0AjQJoekJaUcouBuX0F66VyQG0qezuyEQrye0Z93Ffgug==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-react/-/authorizer-react-1.1.0.tgz",
"integrity": "sha512-8ooyBREFI6ohHApVOPQitFr7T0w0SlpEVZruvU9oqa8OQ77UBxLQh+PCRKKPw7FeQRdCdh/VQyl17W7Xphp1NA==",
"dependencies": {
"@authorizerdev/authorizer-js": "^1.0.0",
"@authorizerdev/authorizer-js": "^1.1.0",
"final-form": "^4.20.2",
"react-final-form": "^6.5.3",
"styled-components": "^5.3.0"
@@ -860,19 +860,19 @@
},
"dependencies": {
"@authorizerdev/authorizer-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-1.0.0.tgz",
"integrity": "sha512-TtXA8y06CIZ5f+nk1tgPiSpGR9neCkGHtmKLqGWjWPdObDfekRU5qMtpC2S2uEljAI53mnueLZKmbd9hrReTag==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-1.1.0.tgz",
"integrity": "sha512-MdEw1SjhIm7pXq20AscHSbnAta2PC3w7GNBY52/OzmlBXUGH3ooUQX/aszbYOse3FlhapcrGrRvg4sNM7faGAg==",
"requires": {
"cross-fetch": "^3.1.5"
}
},
"@authorizerdev/authorizer-react": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-react/-/authorizer-react-1.0.0.tgz",
"integrity": "sha512-lXckUe46LTcH+hFxIxPEewQR1/ktd2awoqZsMZZaa0AjQJoekJaUcouBuX0F66VyQG0qezuyEQrye0Z93Ffgug==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-react/-/authorizer-react-1.1.0.tgz",
"integrity": "sha512-8ooyBREFI6ohHApVOPQitFr7T0w0SlpEVZruvU9oqa8OQ77UBxLQh+PCRKKPw7FeQRdCdh/VQyl17W7Xphp1NA==",
"requires": {
"@authorizerdev/authorizer-js": "^1.0.0",
"@authorizerdev/authorizer-js": "^1.1.0",
"final-form": "^4.20.2",
"react-final-form": "^6.5.3",
"styled-components": "^5.3.0"

View File

@@ -11,7 +11,7 @@
"author": "Lakhan Samani",
"license": "ISC",
"dependencies": {
"@authorizerdev/authorizer-react": "^1.0.0",
"@authorizerdev/authorizer-react": "^1.1.0",
"@types/react": "^17.0.15",
"@types/react-dom": "^17.0.9",
"esbuild": "^0.12.17",

View File

@@ -4,6 +4,12 @@ import { AuthorizerProvider } from '@authorizerdev/authorizer-react';
import Root from './Root';
import { createRandomString } from './utils/common';
declare global {
interface Window {
__authorizer__: any;
}
}
export default function App() {
const searchParams = new URLSearchParams(window.location.search);
const state = searchParams.get('state') || createRandomString();
@@ -24,7 +30,6 @@ export default function App() {
urlProps.redirectURL = window.location.origin + '/app';
}
const globalState: Record<string, string> = {
// @ts-ignore
...window['__authorizer__'],
...urlProps,
};

View File

@@ -1,154 +1,201 @@
import React from "react";
import { Flex, Stack, Center, Text, useMediaQuery } from "@chakra-ui/react";
import React from 'react';
import {
HiddenInputType,
TextInputType,
TextAreaInputType,
} from "../../constants";
import GenerateKeysModal from "../GenerateKeysModal";
import InputField from "../InputField";
Flex,
Stack,
Center,
Text,
useMediaQuery,
Button,
useToast,
} from '@chakra-ui/react';
import {
HiddenInputType,
TextInputType,
TextAreaInputType,
} from '../../constants';
import GenerateKeysModal from '../GenerateKeysModal';
import InputField from '../InputField';
import { copyTextToClipboard } from '../../utils';
const JSTConfigurations = ({
variables,
setVariables,
fieldVisibility,
setFieldVisibility,
SelectInputType,
getData,
HMACEncryptionType,
RSAEncryptionType,
ECDSAEncryptionType,
variables,
setVariables,
fieldVisibility,
setFieldVisibility,
SelectInputType,
getData,
HMACEncryptionType,
RSAEncryptionType,
ECDSAEncryptionType,
}: any) => {
const [isNotSmallerScreen] = useMediaQuery("(min-width:600px)");
const [isNotSmallerScreen] = useMediaQuery('(min-width:600px)');
const toast = useToast();
return (
<div>
{" "}
<Flex
borderRadius={5}
width="100%"
justifyContent="space-between"
alignItems="center"
paddingTop="2%"
>
<Text
fontSize={isNotSmallerScreen ? "md" : "sm"}
fontWeight="bold"
mb={5}
>
JWT (JSON Web Tokens) Configurations
</Text>
<Flex mb={7}>
<GenerateKeysModal jwtType={variables.JWT_TYPE} getData={getData} />
</Flex>
</Flex>
<Stack spacing={6} padding="2% 0%">
<Flex direction={isNotSmallerScreen ? "row" : "column"}>
<Flex w="30%" justifyContent="start" alignItems="center">
<Text fontSize="sm">JWT Type:</Text>
</Flex>
<Flex
w={isNotSmallerScreen ? "70%" : "100%"}
mt={isNotSmallerScreen ? "0" : "2"}
>
<InputField
borderRadius={5}
variables={variables}
setVariables={setVariables}
inputType={SelectInputType}
value={SelectInputType}
options={{
...HMACEncryptionType,
...RSAEncryptionType,
...ECDSAEncryptionType,
}}
/>
</Flex>
</Flex>
{Object.values(HMACEncryptionType).includes(variables.JWT_TYPE) ? (
<Flex direction={isNotSmallerScreen ? "row" : "column"}>
<Flex w="30%" justifyContent="start" alignItems="center">
<Text fontSize="sm">JWT Secret</Text>
</Flex>
<Center
w={isNotSmallerScreen ? "70%" : "100%"}
mt={isNotSmallerScreen ? "0" : "2"}
>
<InputField
borderRadius={5}
variables={variables}
setVariables={setVariables}
fieldVisibility={fieldVisibility}
setFieldVisibility={setFieldVisibility}
inputType={HiddenInputType.JWT_SECRET}
/>
</Center>
</Flex>
) : (
<>
<Flex direction={isNotSmallerScreen ? "row" : "column"}>
<Flex w="30%" justifyContent="start" alignItems="center">
<Text fontSize="sm">Public Key</Text>
</Flex>
<Center
w={isNotSmallerScreen ? "70%" : "100%"}
mt={isNotSmallerScreen ? "0" : "2"}
>
<InputField
borderRadius={5}
variables={variables}
setVariables={setVariables}
inputType={TextAreaInputType.JWT_PUBLIC_KEY}
placeholder="Add public key here"
minH="25vh"
/>
</Center>
</Flex>
<Flex direction={isNotSmallerScreen ? "row" : "column"}>
<Flex w="30%" justifyContent="start" alignItems="center">
<Text fontSize="sm">Private Key</Text>
</Flex>
<Center
w={isNotSmallerScreen ? "70%" : "100%"}
mt={isNotSmallerScreen ? "0" : "2"}
>
<InputField
borderRadius={5}
variables={variables}
setVariables={setVariables}
inputType={TextAreaInputType.JWT_PRIVATE_KEY}
placeholder="Add private key here"
minH="25vh"
/>
</Center>
</Flex>
</>
)}
<Flex direction={isNotSmallerScreen ? "row" : "column"}>
<Flex
w={isNotSmallerScreen ? "30%" : "40%"}
justifyContent="start"
alignItems="center"
>
<Text fontSize="sm" orientation="vertical">
JWT Role Claim:
</Text>
</Flex>
<Center
w={isNotSmallerScreen ? "70%" : "100%"}
mt={isNotSmallerScreen ? "0" : "2"}
>
<InputField
borderRadius={5}
variables={variables}
setVariables={setVariables}
inputType={TextInputType.JWT_ROLE_CLAIM}
/>
</Center>
</Flex>
</Stack>
</div>
);
const copyJSON = async () => {
try {
await copyTextToClipboard(
JSON.stringify({
type: variables.JWT_TYPE,
key: variables.JWT_PUBLIC_KEY || variables.JWT_SECRET,
})
);
toast({
title: `JWT config copied successfully`,
isClosable: true,
status: 'success',
position: 'bottom-right',
});
} catch (err) {
console.error({
message: `Failed to copy JWT config`,
error: err,
});
toast({
title: `Failed to copy JWT config`,
isClosable: true,
status: 'error',
position: 'bottom-right',
});
}
};
return (
<div>
{' '}
<Flex
borderRadius={5}
width="100%"
justifyContent="space-between"
alignItems="center"
paddingTop="2%"
>
<Text
fontSize={isNotSmallerScreen ? 'md' : 'sm'}
fontWeight="bold"
mb={5}
>
JWT (JSON Web Tokens) Configurations
</Text>
<Flex mb={7}>
<Button
colorScheme="blue"
h="1.75rem"
size="sm"
variant="ghost"
onClick={copyJSON}
>
Copy As JSON Config
</Button>
<GenerateKeysModal jwtType={variables.JWT_TYPE} getData={getData} />
</Flex>
</Flex>
<Stack spacing={6} padding="2% 0%">
<Flex direction={isNotSmallerScreen ? 'row' : 'column'}>
<Flex w="30%" justifyContent="start" alignItems="center">
<Text fontSize="sm">JWT Type:</Text>
</Flex>
<Flex
w={isNotSmallerScreen ? '70%' : '100%'}
mt={isNotSmallerScreen ? '0' : '2'}
>
<InputField
borderRadius={5}
variables={variables}
setVariables={setVariables}
inputType={SelectInputType}
value={SelectInputType}
options={{
...HMACEncryptionType,
...RSAEncryptionType,
...ECDSAEncryptionType,
}}
/>
</Flex>
</Flex>
{Object.values(HMACEncryptionType).includes(variables.JWT_TYPE) ? (
<Flex direction={isNotSmallerScreen ? 'row' : 'column'}>
<Flex w="30%" justifyContent="start" alignItems="center">
<Text fontSize="sm">JWT Secret</Text>
</Flex>
<Center
w={isNotSmallerScreen ? '70%' : '100%'}
mt={isNotSmallerScreen ? '0' : '2'}
>
<InputField
borderRadius={5}
variables={variables}
setVariables={setVariables}
fieldVisibility={fieldVisibility}
setFieldVisibility={setFieldVisibility}
inputType={HiddenInputType.JWT_SECRET}
/>
</Center>
</Flex>
) : (
<>
<Flex direction={isNotSmallerScreen ? 'row' : 'column'}>
<Flex w="30%" justifyContent="start" alignItems="center">
<Text fontSize="sm">Public Key</Text>
</Flex>
<Center
w={isNotSmallerScreen ? '70%' : '100%'}
mt={isNotSmallerScreen ? '0' : '2'}
>
<InputField
borderRadius={5}
variables={variables}
setVariables={setVariables}
inputType={TextAreaInputType.JWT_PUBLIC_KEY}
placeholder="Add public key here"
minH="25vh"
/>
</Center>
</Flex>
<Flex direction={isNotSmallerScreen ? 'row' : 'column'}>
<Flex w="30%" justifyContent="start" alignItems="center">
<Text fontSize="sm">Private Key</Text>
</Flex>
<Center
w={isNotSmallerScreen ? '70%' : '100%'}
mt={isNotSmallerScreen ? '0' : '2'}
>
<InputField
borderRadius={5}
variables={variables}
setVariables={setVariables}
inputType={TextAreaInputType.JWT_PRIVATE_KEY}
placeholder="Add private key here"
minH="25vh"
/>
</Center>
</Flex>
</>
)}
<Flex direction={isNotSmallerScreen ? 'row' : 'column'}>
<Flex
w={isNotSmallerScreen ? '30%' : '40%'}
justifyContent="start"
alignItems="center"
>
<Text fontSize="sm" orientation="vertical">
JWT Role Claim:
</Text>
</Flex>
<Center
w={isNotSmallerScreen ? '70%' : '100%'}
mt={isNotSmallerScreen ? '0' : '2'}
>
<InputField
borderRadius={5}
variables={variables}
setVariables={setVariables}
inputType={TextInputType.JWT_ROLE_CLAIM}
/>
</Center>
</Flex>
</Stack>
</div>
);
};
export default JSTConfigurations;
export default JSTConfigurations;

View File

@@ -15,6 +15,7 @@ import {
FaFacebookF,
FaLinkedin,
FaApple,
FaTwitter,
} from 'react-icons/fa';
import { TextInputType, HiddenInputType } from '../../constants';
@@ -264,6 +265,44 @@ const OAuthConfig = ({
/>
</Center>
</Flex>
<Flex direction={isNotSmallerScreen ? 'row' : 'column'}>
<Center
w={isNotSmallerScreen ? '55px' : '35px'}
h="35px"
marginRight="1.5%"
border="1px solid #3b5998"
borderRadius="5px"
>
<FaTwitter />
</Center>
<Center
w={isNotSmallerScreen ? '70%' : '100%'}
mt={isNotSmallerScreen ? '0' : '3'}
marginRight="1.5%"
>
<InputField
borderRadius={5}
variables={envVariables}
setVariables={setVariables}
inputType={TextInputType.TWITTER_CLIENT_ID}
placeholder="Twitter Client ID"
/>
</Center>
<Center
w={isNotSmallerScreen ? '70%' : '100%'}
mt={isNotSmallerScreen ? '0' : '3'}
>
<InputField
borderRadius={5}
variables={envVariables}
setVariables={setVariables}
fieldVisibility={fieldVisibility}
setFieldVisibility={setFieldVisibility}
inputType={HiddenInputType.TWITTER_CLIENT_SECRET}
placeholder="Twitter Client Secret"
/>
</Center>
</Flex>
</Stack>
</Box>
</div>

View File

@@ -9,6 +9,7 @@ export const TextInputType = {
FACEBOOK_CLIENT_ID: 'FACEBOOK_CLIENT_ID',
LINKEDIN_CLIENT_ID: 'LINKEDIN_CLIENT_ID',
APPLE_CLIENT_ID: 'APPLE_CLIENT_ID',
TWITTER_CLIENT_ID: 'TWITTER_CLIENT_ID',
JWT_ROLE_CLAIM: 'JWT_ROLE_CLAIM',
REDIS_URL: 'REDIS_URL',
SMTP_HOST: 'SMTP_HOST',
@@ -35,6 +36,7 @@ export const HiddenInputType = {
FACEBOOK_CLIENT_SECRET: 'FACEBOOK_CLIENT_SECRET',
LINKEDIN_CLIENT_SECRET: 'LINKEDIN_CLIENT_SECRET',
APPLE_CLIENT_SECRET: 'APPLE_CLIENT_SECRET',
TWITTER_CLIENT_SECRET: 'TWITTER_CLIENT_SECRET',
JWT_SECRET: 'JWT_SECRET',
SMTP_PASSWORD: 'SMTP_PASSWORD',
ADMIN_SECRET: 'ADMIN_SECRET',
@@ -110,6 +112,8 @@ export interface envVarTypes {
LINKEDIN_CLIENT_SECRET: string;
APPLE_CLIENT_ID: string;
APPLE_CLIENT_SECRET: string;
TWITTER_CLIENT_ID: string;
TWITTER_CLIENT_SECRET: string;
ROLES: [string] | [];
DEFAULT_ROLES: [string] | [];
PROTECTED_ROLES: [string] | [];

View File

@@ -30,6 +30,8 @@ export const EnvVariablesQuery = `
LINKEDIN_CLIENT_SECRET
APPLE_CLIENT_ID
APPLE_CLIENT_SECRET
TWITTER_CLIENT_ID
TWITTER_CLIENT_SECRET
DEFAULT_ROLES
PROTECTED_ROLES
ROLES

View File

@@ -50,6 +50,8 @@ const Environment = () => {
LINKEDIN_CLIENT_SECRET: '',
APPLE_CLIENT_ID: '',
APPLE_CLIENT_SECRET: '',
TWITTER_CLIENT_ID: '',
TWITTER_CLIENT_SECRET: '',
ROLES: [],
DEFAULT_ROLES: [],
PROTECTED_ROLES: [],
@@ -92,6 +94,7 @@ const Environment = () => {
FACEBOOK_CLIENT_SECRET: false,
LINKEDIN_CLIENT_SECRET: false,
APPLE_CLIENT_SECRET: false,
TWITTER_CLIENT_SECRET: false,
JWT_SECRET: false,
SMTP_PASSWORD: false,
ADMIN_SECRET: false,

View File

@@ -29,6 +29,7 @@ import {
MenuItem,
useToast,
Spinner,
TableContainer
} from '@chakra-ui/react';
import {
FaAngleLeft,
@@ -262,9 +263,8 @@ export default function Users() {
.toPromise();
if (res.data?._update_user?.id) {
toast({
title: `Multi factor authentication ${
user.is_multi_factor_auth_enabled ? 'disabled' : 'enabled'
} for user`,
title: `Multi factor authentication ${user.is_multi_factor_auth_enabled ? 'disabled' : 'enabled'
} for user`,
isClosable: true,
status: 'success',
position: 'bottom-right',
@@ -293,260 +293,262 @@ export default function Users() {
</Flex>
{!loading ? (
userList.length > 0 ? (
<Table variant="simple">
<Thead>
<Tr>
<Th>Email</Th>
<Th>Created At</Th>
<Th>Signup Methods</Th>
<Th>Roles</Th>
<Th>Verified</Th>
<Th>Access</Th>
<Th>
<Tooltip label="MultiFactor Authentication Enabled / Disabled">
MFA
</Tooltip>
</Th>
<Th>Actions</Th>
</Tr>
</Thead>
<Tbody>
{userList.map((user: userDataTypes) => {
const { email_verified, created_at, ...rest }: any = user;
return (
<Tr key={user.id} style={{ fontSize: 14 }}>
<Td maxW="300">{user.email}</Td>
<Td>
{dayjs(user.created_at * 1000).format('MMM DD, YYYY')}
</Td>
<Td>{user.signup_methods}</Td>
<Td>{user.roles.join(', ')}</Td>
<Td>
<Tag
size="sm"
variant="outline"
colorScheme={user.email_verified ? 'green' : 'yellow'}
>
{user.email_verified.toString()}
</Tag>
</Td>
<Td>
<Tag
size="sm"
variant="outline"
colorScheme={user.revoked_timestamp ? 'red' : 'green'}
>
{user.revoked_timestamp ? 'Revoked' : 'Enabled'}
</Tag>
</Td>
<Td>
<Tag
size="sm"
variant="outline"
colorScheme={
user.is_multi_factor_auth_enabled ? 'green' : 'red'
}
>
{user.is_multi_factor_auth_enabled
? 'Enabled'
: 'Disabled'}
</Tag>
</Td>
<Td>
<Menu>
<MenuButton as={Button} variant="unstyled" size="sm">
<Flex
justifyContent="space-between"
alignItems="center"
>
<Text fontSize="sm" fontWeight="light">
Menu
</Text>
<FaAngleDown style={{ marginLeft: 10 }} />
</Flex>
</MenuButton>
<MenuList>
{!user.email_verified && (
<MenuItem
onClick={() => userVerificationHandler(user)}
<TableContainer>
<Table variant="simple">
<Thead>
<Tr>
<Th>Email</Th>
<Th>Created At</Th>
<Th>Signup Methods</Th>
<Th>Roles</Th>
<Th>Verified</Th>
<Th>Access</Th>
<Th>
<Tooltip label="MultiFactor Authentication Enabled / Disabled">
MFA
</Tooltip>
</Th>
<Th>Actions</Th>
</Tr>
</Thead>
<Tbody>
{userList.map((user: userDataTypes) => {
const { email_verified, created_at, ...rest }: any = user;
return (
<Tr key={user.id} style={{ fontSize: 14 }}>
<Td maxW="300">{user.email}</Td>
<Td>
{dayjs(user.created_at * 1000).format('MMM DD, YYYY')}
</Td>
<Td>{user.signup_methods}</Td>
<Td>{user.roles.join(', ')}</Td>
<Td>
<Tag
size="sm"
variant="outline"
colorScheme={user.email_verified ? 'green' : 'yellow'}
>
{user.email_verified.toString()}
</Tag>
</Td>
<Td>
<Tag
size="sm"
variant="outline"
colorScheme={user.revoked_timestamp ? 'red' : 'green'}
>
{user.revoked_timestamp ? 'Revoked' : 'Enabled'}
</Tag>
</Td>
<Td>
<Tag
size="sm"
variant="outline"
colorScheme={
user.is_multi_factor_auth_enabled ? 'green' : 'red'
}
>
{user.is_multi_factor_auth_enabled
? 'Enabled'
: 'Disabled'}
</Tag>
</Td>
<Td>
<Menu>
<MenuButton as={Button} variant="unstyled" size="sm">
<Flex
justifyContent="space-between"
alignItems="center"
>
Verify User
</MenuItem>
)}
<EditUserModal
user={rest}
updateUserList={updateUserList}
/>
<DeleteUserModal
user={rest}
updateUserList={updateUserList}
/>
{user.revoked_timestamp ? (
<MenuItem
onClick={() =>
updateAccessHandler(
user.id,
updateAccessActions.ENABLE
)
}
>
Enable Access
</MenuItem>
) : (
<MenuItem
onClick={() =>
updateAccessHandler(
user.id,
updateAccessActions.REVOKE
)
}
>
Revoke Access
</MenuItem>
)}
{user.is_multi_factor_auth_enabled ? (
<MenuItem
onClick={() => multiFactorAuthUpdateHandler(user)}
>
Disable MultiFactor Authentication
</MenuItem>
) : (
<MenuItem
onClick={() => multiFactorAuthUpdateHandler(user)}
>
Enable MultiFactor Authentication
</MenuItem>
)}
</MenuList>
</Menu>
</Td>
</Tr>
);
})}
</Tbody>
{(paginationProps.maxPages > 1 || paginationProps.total >= 5) && (
<TableCaption>
<Flex
justifyContent="space-between"
alignItems="center"
m="2% 0"
>
<Flex flex="1">
<Tooltip label="First Page">
<IconButton
aria-label="icon button"
onClick={() =>
<Text fontSize="sm" fontWeight="light">
Menu
</Text>
<FaAngleDown style={{ marginLeft: 10 }} />
</Flex>
</MenuButton>
<MenuList>
{!user.email_verified && (
<MenuItem
onClick={() => userVerificationHandler(user)}
>
Verify User
</MenuItem>
)}
<EditUserModal
user={rest}
updateUserList={updateUserList}
/>
<DeleteUserModal
user={rest}
updateUserList={updateUserList}
/>
{user.revoked_timestamp ? (
<MenuItem
onClick={() =>
updateAccessHandler(
user.id,
updateAccessActions.ENABLE
)
}
>
Enable Access
</MenuItem>
) : (
<MenuItem
onClick={() =>
updateAccessHandler(
user.id,
updateAccessActions.REVOKE
)
}
>
Revoke Access
</MenuItem>
)}
{user.is_multi_factor_auth_enabled ? (
<MenuItem
onClick={() => multiFactorAuthUpdateHandler(user)}
>
Disable MultiFactor Authentication
</MenuItem>
) : (
<MenuItem
onClick={() => multiFactorAuthUpdateHandler(user)}
>
Enable MultiFactor Authentication
</MenuItem>
)}
</MenuList>
</Menu>
</Td>
</Tr>
);
})}
</Tbody>
{(paginationProps.maxPages > 1 || paginationProps.total >= 5) && (
<TableCaption>
<Flex
justifyContent="space-between"
alignItems="center"
m="2% 0"
>
<Flex flex="1">
<Tooltip label="First Page">
<IconButton
aria-label="icon button"
onClick={() =>
paginationHandler({
page: 1,
})
}
isDisabled={paginationProps.page <= 1}
mr={4}
icon={<FaAngleDoubleLeft />}
/>
</Tooltip>
<Tooltip label="Previous Page">
<IconButton
aria-label="icon button"
onClick={() =>
paginationHandler({
page: paginationProps.page - 1,
})
}
isDisabled={paginationProps.page <= 1}
icon={<FaAngleLeft />}
/>
</Tooltip>
</Flex>
<Flex
flex="8"
justifyContent="space-evenly"
alignItems="center"
>
<Text mr={8}>
Page{' '}
<Text fontWeight="bold" as="span">
{paginationProps.page}
</Text>{' '}
of{' '}
<Text fontWeight="bold" as="span">
{paginationProps.maxPages}
</Text>
</Text>
<Flex alignItems="center">
<Text flexShrink="0">Go to page:</Text>{' '}
<NumberInput
ml={2}
mr={8}
w={28}
min={1}
max={paginationProps.maxPages}
onChange={(value) =>
paginationHandler({
page: parseInt(value),
})
}
value={paginationProps.page}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</Flex>
<Select
w={32}
value={paginationProps.limit}
onChange={(e) =>
paginationHandler({
page: 1,
limit: parseInt(e.target.value),
})
}
isDisabled={paginationProps.page <= 1}
mr={4}
icon={<FaAngleDoubleLeft />}
/>
</Tooltip>
<Tooltip label="Previous Page">
<IconButton
aria-label="icon button"
onClick={() =>
paginationHandler({
page: paginationProps.page - 1,
})
}
isDisabled={paginationProps.page <= 1}
icon={<FaAngleLeft />}
/>
</Tooltip>
</Flex>
<Flex
flex="8"
justifyContent="space-evenly"
alignItems="center"
>
<Text mr={8}>
Page{' '}
<Text fontWeight="bold" as="span">
{paginationProps.page}
</Text>{' '}
of{' '}
<Text fontWeight="bold" as="span">
{paginationProps.maxPages}
</Text>
</Text>
<Flex alignItems="center">
<Text flexShrink="0">Go to page:</Text>{' '}
<NumberInput
ml={2}
mr={8}
w={28}
min={1}
max={paginationProps.maxPages}
onChange={(value) =>
paginationHandler({
page: parseInt(value),
})
}
value={paginationProps.page}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
{getLimits(paginationProps).map((pageSize) => (
<option key={pageSize} value={pageSize}>
Show {pageSize}
</option>
))}
</Select>
</Flex>
<Flex flex="1">
<Tooltip label="Next Page">
<IconButton
aria-label="icon button"
onClick={() =>
paginationHandler({
page: paginationProps.page + 1,
})
}
isDisabled={
paginationProps.page >= paginationProps.maxPages
}
icon={<FaAngleRight />}
/>
</Tooltip>
<Tooltip label="Last Page">
<IconButton
aria-label="icon button"
onClick={() =>
paginationHandler({
page: paginationProps.maxPages,
})
}
isDisabled={
paginationProps.page >= paginationProps.maxPages
}
ml={4}
icon={<FaAngleDoubleRight />}
/>
</Tooltip>
</Flex>
<Select
w={32}
value={paginationProps.limit}
onChange={(e) =>
paginationHandler({
page: 1,
limit: parseInt(e.target.value),
})
}
>
{getLimits(paginationProps).map((pageSize) => (
<option key={pageSize} value={pageSize}>
Show {pageSize}
</option>
))}
</Select>
</Flex>
<Flex flex="1">
<Tooltip label="Next Page">
<IconButton
aria-label="icon button"
onClick={() =>
paginationHandler({
page: paginationProps.page + 1,
})
}
isDisabled={
paginationProps.page >= paginationProps.maxPages
}
icon={<FaAngleRight />}
/>
</Tooltip>
<Tooltip label="Last Page">
<IconButton
aria-label="icon button"
onClick={() =>
paginationHandler({
page: paginationProps.maxPages,
})
}
isDisabled={
paginationProps.page >= paginationProps.maxPages
}
ml={4}
icon={<FaAngleDoubleRight />}
/>
</Tooltip>
</Flex>
</Flex>
</TableCaption>
)}
</Table>
</TableCaption>
)}
</Table>
</TableContainer>
) : (
<Flex
flexDirection="column"

View File

@@ -29,19 +29,16 @@ const fallbackCopyTextToClipboard = (text: string) => {
document.body.removeChild(textArea);
};
export const copyTextToClipboard = (text: string) => {
export const copyTextToClipboard = async (text: string) => {
if (!navigator.clipboard) {
fallbackCopyTextToClipboard(text);
return;
}
navigator.clipboard.writeText(text).then(
() => {
console.log('Async: Copying to clipboard was successful!');
},
(err) => {
console.error('Async: Could not copy text: ', err);
}
);
try {
navigator.clipboard.writeText(text);
} catch (err) {
throw err;
}
};
export const getObjectDiff = (obj1: any, obj2: any) => {

View File

@@ -15,4 +15,6 @@ const (
AuthRecipeMethodLinkedIn = "linkedin"
// AuthRecipeMethodApple is the apple auth method
AuthRecipeMethodApple = "apple"
// AuthRecipeMethodTwitter is the twitter auth method
AuthRecipeMethodTwitter = "twitter"
)

View File

@@ -85,6 +85,10 @@ const (
EnvKeyAppleClientID = "APPLE_CLIENT_ID"
// EnvKeyAppleClientSecret key for env variable APPLE_CLIENT_SECRET
EnvKeyAppleClientSecret = "APPLE_CLIENT_SECRET"
// EnvKeyTwitterClientID key for env variable TWITTER_CLIENT_ID
EnvKeyTwitterClientID = "TWITTER_CLIENT_ID"
// EnvKeyTwitterClientSecret key for env variable TWITTER_CLIENT_SECRET
EnvKeyTwitterClientSecret = "TWITTER_CLIENT_SECRET"
// EnvKeyOrganizationName key for env variable ORGANIZATION_NAME
EnvKeyOrganizationName = "ORGANIZATION_NAME"
// EnvKeyOrganizationLogo key for env variable ORGANIZATION_LOGO

View File

@@ -9,9 +9,11 @@ const (
// Ref: https://docs.github.com/en/developers/apps/building-github-apps/identifying-and-authorizing-users-for-github-apps#3-your-github-app-accesses-the-api-with-the-users-access-token
GithubUserInfoURL = "https://api.github.com/user"
// Get github user emails when user info email is empty Ref: https://stackoverflow.com/a/35387123
GithubUserEmails = "https://api/github.com/user/emails"
GithubUserEmails = "https://api.github.com/user/emails"
// Ref: https://docs.microsoft.com/en-us/linkedin/shared/integrations/people/profile-api
LinkedInUserInfoURL = "https://api.linkedin.com/v2/me?projection=(id,localizedFirstName,localizedLastName,emailAddress,profilePicture(displayImage~:playableStreams))"
LinkedInEmailURL = "https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))"
TwitterUserInfoURL = "https://api.twitter.com/2/users/me?user.fields=id,name,profile_image_url,username"
)

View File

@@ -1 +0,0 @@
package email

16
server/env/env.go vendored
View File

@@ -72,6 +72,8 @@ func InitAllEnv() error {
osLinkedInClientSecret := os.Getenv(constants.EnvKeyLinkedInClientSecret)
osAppleClientID := os.Getenv(constants.EnvKeyAppleClientID)
osAppleClientSecret := os.Getenv(constants.EnvKeyAppleClientSecret)
osTwitterClientID := os.Getenv(constants.EnvKeyTwitterClientID)
osTwitterClientSecret := os.Getenv(constants.EnvKeyTwitterClientSecret)
osResetPasswordURL := os.Getenv(constants.EnvKeyResetPasswordURL)
osOrganizationName := os.Getenv(constants.EnvKeyOrganizationName)
osOrganizationLogo := os.Getenv(constants.EnvKeyOrganizationLogo)
@@ -380,6 +382,20 @@ func InitAllEnv() error {
envData[constants.EnvKeyAppleClientSecret] = osAppleClientSecret
}
if val, ok := envData[constants.EnvKeyTwitterClientID]; !ok || val == "" {
envData[constants.EnvKeyTwitterClientID] = osTwitterClientID
}
if osTwitterClientID != "" && envData[constants.EnvKeyTwitterClientID] != osTwitterClientID {
envData[constants.EnvKeyTwitterClientID] = osTwitterClientID
}
if val, ok := envData[constants.EnvKeyTwitterClientSecret]; !ok || val == "" {
envData[constants.EnvKeyTwitterClientSecret] = osTwitterClientSecret
}
if osTwitterClientSecret != "" && envData[constants.EnvKeyTwitterClientSecret] != osTwitterClientSecret {
envData[constants.EnvKeyTwitterClientSecret] = osTwitterClientSecret
}
if val, ok := envData[constants.EnvKeyResetPasswordURL]; !ok || val == "" {
envData[constants.EnvKeyResetPasswordURL] = strings.TrimPrefix(osResetPasswordURL, "/")
}

View File

@@ -119,6 +119,8 @@ type ComplexityRoot struct {
SMTPPort func(childComplexity int) int
SMTPUsername func(childComplexity int) int
SenderEmail func(childComplexity int) int
TwitterClientID func(childComplexity int) int
TwitterClientSecret func(childComplexity int) int
}
Error struct {
@@ -145,6 +147,7 @@ type ComplexityRoot struct {
IsMultiFactorAuthEnabled func(childComplexity int) int
IsSignUpEnabled func(childComplexity int) int
IsStrongPasswordEnabled func(childComplexity int) int
IsTwitterLoginEnabled func(childComplexity int) int
Version func(childComplexity int) int
}
@@ -813,6 +816,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Env.SenderEmail(childComplexity), true
case "Env.TWITTER_CLIENT_ID":
if e.complexity.Env.TwitterClientID == nil {
break
}
return e.complexity.Env.TwitterClientID(childComplexity), true
case "Env.TWITTER_CLIENT_SECRET":
if e.complexity.Env.TwitterClientSecret == nil {
break
}
return e.complexity.Env.TwitterClientSecret(childComplexity), true
case "Error.message":
if e.complexity.Error.Message == nil {
break
@@ -932,6 +949,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Meta.IsStrongPasswordEnabled(childComplexity), true
case "Meta.is_twitter_login_enabled":
if e.complexity.Meta.IsTwitterLoginEnabled == nil {
break
}
return e.complexity.Meta.IsTwitterLoginEnabled(childComplexity), true
case "Meta.version":
if e.complexity.Meta.Version == nil {
break
@@ -1893,6 +1917,7 @@ type Meta {
is_github_login_enabled: Boolean!
is_linkedin_login_enabled: Boolean!
is_apple_login_enabled: Boolean!
is_twitter_login_enabled: Boolean!
is_email_verification_enabled: Boolean!
is_basic_authentication_enabled: Boolean!
is_magic_link_login_enabled: Boolean!
@@ -2014,6 +2039,8 @@ type Env {
LINKEDIN_CLIENT_SECRET: String
APPLE_CLIENT_ID: String
APPLE_CLIENT_SECRET: String
TWITTER_CLIENT_ID: String
TWITTER_CLIENT_SECRET: String
ORGANIZATION_NAME: String
ORGANIZATION_LOGO: String
}
@@ -2118,6 +2145,8 @@ input UpdateEnvInput {
LINKEDIN_CLIENT_SECRET: String
APPLE_CLIENT_ID: String
APPLE_CLIENT_SECRET: String
TWITTER_CLIENT_ID: String
TWITTER_CLIENT_SECRET: String
ORGANIZATION_NAME: String
ORGANIZATION_LOGO: String
}
@@ -5054,6 +5083,70 @@ func (ec *executionContext) _Env_APPLE_CLIENT_SECRET(ctx context.Context, field
return ec.marshalOString2ᚖstring(ctx, field.Selections, res)
}
func (ec *executionContext) _Env_TWITTER_CLIENT_ID(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.TwitterClientID, 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_TWITTER_CLIENT_SECRET(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.TwitterClientSecret, 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_ORGANIZATION_NAME(ctx context.Context, field graphql.CollectedField, obj *model.Env) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
@@ -5529,6 +5622,41 @@ func (ec *executionContext) _Meta_is_apple_login_enabled(ctx context.Context, fi
return ec.marshalNBoolean2bool(ctx, field.Selections, res)
}
func (ec *executionContext) _Meta_is_twitter_login_enabled(ctx context.Context, field graphql.CollectedField, obj *model.Meta) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "Meta",
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.IsTwitterLoginEnabled, 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) _Meta_is_email_verification_enabled(ctx context.Context, field graphql.CollectedField, obj *model.Meta) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
@@ -11720,6 +11848,22 @@ func (ec *executionContext) unmarshalInputUpdateEnvInput(ctx context.Context, ob
if err != nil {
return it, err
}
case "TWITTER_CLIENT_ID":
var err error
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("TWITTER_CLIENT_ID"))
it.TwitterClientID, err = ec.unmarshalOString2ᚖstring(ctx, v)
if err != nil {
return it, err
}
case "TWITTER_CLIENT_SECRET":
var err error
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("TWITTER_CLIENT_SECRET"))
it.TwitterClientSecret, err = ec.unmarshalOString2ᚖstring(ctx, v)
if err != nil {
return it, err
}
case "ORGANIZATION_NAME":
var err error
@@ -12421,6 +12565,10 @@ func (ec *executionContext) _Env(ctx context.Context, sel ast.SelectionSet, obj
out.Values[i] = ec._Env_APPLE_CLIENT_ID(ctx, field, obj)
case "APPLE_CLIENT_SECRET":
out.Values[i] = ec._Env_APPLE_CLIENT_SECRET(ctx, field, obj)
case "TWITTER_CLIENT_ID":
out.Values[i] = ec._Env_TWITTER_CLIENT_ID(ctx, field, obj)
case "TWITTER_CLIENT_SECRET":
out.Values[i] = ec._Env_TWITTER_CLIENT_SECRET(ctx, field, obj)
case "ORGANIZATION_NAME":
out.Values[i] = ec._Env_ORGANIZATION_NAME(ctx, field, obj)
case "ORGANIZATION_LOGO":
@@ -12542,6 +12690,11 @@ func (ec *executionContext) _Meta(ctx context.Context, sel ast.SelectionSet, obj
if out.Values[i] == graphql.Null {
invalids++
}
case "is_twitter_login_enabled":
out.Values[i] = ec._Meta_is_twitter_login_enabled(ctx, field, obj)
if out.Values[i] == graphql.Null {
invalids++
}
case "is_email_verification_enabled":
out.Values[i] = ec._Meta_is_email_verification_enabled(ctx, field, obj)
if out.Values[i] == graphql.Null {

View File

@@ -106,6 +106,8 @@ type Env struct {
LinkedinClientSecret *string `json:"LINKEDIN_CLIENT_SECRET"`
AppleClientID *string `json:"APPLE_CLIENT_ID"`
AppleClientSecret *string `json:"APPLE_CLIENT_SECRET"`
TwitterClientID *string `json:"TWITTER_CLIENT_ID"`
TwitterClientSecret *string `json:"TWITTER_CLIENT_SECRET"`
OrganizationName *string `json:"ORGANIZATION_NAME"`
OrganizationLogo *string `json:"ORGANIZATION_LOGO"`
}
@@ -164,6 +166,7 @@ type Meta struct {
IsGithubLoginEnabled bool `json:"is_github_login_enabled"`
IsLinkedinLoginEnabled bool `json:"is_linkedin_login_enabled"`
IsAppleLoginEnabled bool `json:"is_apple_login_enabled"`
IsTwitterLoginEnabled bool `json:"is_twitter_login_enabled"`
IsEmailVerificationEnabled bool `json:"is_email_verification_enabled"`
IsBasicAuthenticationEnabled bool `json:"is_basic_authentication_enabled"`
IsMagicLinkLoginEnabled bool `json:"is_magic_link_login_enabled"`
@@ -297,6 +300,8 @@ type UpdateEnvInput struct {
LinkedinClientSecret *string `json:"LINKEDIN_CLIENT_SECRET"`
AppleClientID *string `json:"APPLE_CLIENT_ID"`
AppleClientSecret *string `json:"APPLE_CLIENT_SECRET"`
TwitterClientID *string `json:"TWITTER_CLIENT_ID"`
TwitterClientSecret *string `json:"TWITTER_CLIENT_SECRET"`
OrganizationName *string `json:"ORGANIZATION_NAME"`
OrganizationLogo *string `json:"ORGANIZATION_LOGO"`
}

View File

@@ -20,6 +20,7 @@ type Meta {
is_github_login_enabled: Boolean!
is_linkedin_login_enabled: Boolean!
is_apple_login_enabled: Boolean!
is_twitter_login_enabled: Boolean!
is_email_verification_enabled: Boolean!
is_basic_authentication_enabled: Boolean!
is_magic_link_login_enabled: Boolean!
@@ -141,6 +142,8 @@ type Env {
LINKEDIN_CLIENT_SECRET: String
APPLE_CLIENT_ID: String
APPLE_CLIENT_SECRET: String
TWITTER_CLIENT_ID: String
TWITTER_CLIENT_SECRET: String
ORGANIZATION_NAME: String
ORGANIZATION_LOGO: String
}
@@ -245,6 +248,8 @@ input UpdateEnvInput {
LINKEDIN_CLIENT_SECRET: String
APPLE_CLIENT_ID: String
APPLE_CLIENT_SECRET: String
TWITTER_CLIENT_ID: String
TWITTER_CLIENT_SECRET: String
ORGANIZATION_NAME: String
ORGANIZATION_LOGO: String
}

View File

@@ -248,7 +248,7 @@ func AuthorizeHandler() gin.HandlerFunc {
return
}
memorystore.Provider.SetUserSession(user.ID, constants.TokenTypeSessionToken+"_"+newSessionTokenData.Nonce, newSessionToken)
memorystore.Provider.SetUserSession(sessionKey, constants.TokenTypeSessionToken+"_"+newSessionTokenData.Nonce, newSessionToken)
cookie.SetSession(gc, newSessionToken)
code := uuid.New().String()
memorystore.Provider.SetState(codeChallenge, code+"@"+newSessionToken)

View File

@@ -67,6 +67,8 @@ func OAuthCallbackHandler() gin.HandlerFunc {
user, err = processLinkedInUserInfo(code)
case constants.AuthRecipeMethodApple:
user, err = processAppleUserInfo(code)
case constants.AuthRecipeMethodTwitter:
user, err = processTwitterUserInfo(code, sessionState)
default:
log.Info("Invalid oauth provider")
err = fmt.Errorf(`invalid oauth provider`)
@@ -285,9 +287,9 @@ func processGithubUserInfo(code string) (models.User, error) {
log.Debug("Failed to create github user info request: ", err)
return user, fmt.Errorf("error creating github user info request: %s", err.Error())
}
req.Header = http.Header{
"Authorization": []string{fmt.Sprintf("token %s", oauth2Token.AccessToken)},
}
req.Header.Set(
"Authorization", fmt.Sprintf("token %s", oauth2Token.AccessToken),
)
response, err := client.Do(req)
if err != nil {
@@ -329,14 +331,14 @@ func processGithubUserInfo(code string) (models.User, error) {
}
// fetch using /users/email endpoint
req, err := http.NewRequest("GET", constants.GithubUserEmails, nil)
req, err := http.NewRequest(http.MethodGet, constants.GithubUserEmails, nil)
if err != nil {
log.Debug("Failed to create github emails request: ", err)
return user, fmt.Errorf("error creating github user info request: %s", err.Error())
}
req.Header = http.Header{
"Authorization": []string{fmt.Sprintf("token %s", oauth2Token.AccessToken)},
}
req.Header.Set(
"Authorization", fmt.Sprintf("token %s", oauth2Token.AccessToken),
)
response, err := client.Do(req)
if err != nil {
@@ -564,3 +566,70 @@ func processAppleUserInfo(code string) (models.User, error) {
return user, err
}
func processTwitterUserInfo(code, verifier string) (models.User, error) {
user := models.User{}
oauth2Token, err := oauth.OAuthProviders.TwitterConfig.Exchange(oauth2.NoContext, code, oauth2.SetAuthURLParam("code_verifier", verifier))
if err != nil {
log.Debug("Failed to exchange code for token: ", err)
return user, fmt.Errorf("invalid twitter exchange code: %s", err.Error())
}
client := http.Client{}
req, err := http.NewRequest("GET", constants.TwitterUserInfoURL, nil)
if err != nil {
log.Debug("Failed to create Twitter user info request: ", err)
return user, fmt.Errorf("error creating Twitter user info request: %s", err.Error())
}
req.Header = http.Header{
"Authorization": []string{fmt.Sprintf("Bearer %s", oauth2Token.AccessToken)},
}
response, err := client.Do(req)
if err != nil {
log.Debug("Failed to request Twitter user info: ", err)
return user, err
}
defer response.Body.Close()
body, err := ioutil.ReadAll(response.Body)
if err != nil {
log.Debug("Failed to read Twitter user info response body: ", err)
return user, fmt.Errorf("failed to read Twitter response body: %s", err.Error())
}
if response.StatusCode >= 400 {
log.Debug("Failed to request Twitter user info: ", string(body))
return user, fmt.Errorf("failed to request Twitter user info: %s", string(body))
}
responseRawData := make(map[string]interface{})
json.Unmarshal(body, &responseRawData)
userRawData := responseRawData["data"].(map[string]interface{})
// log.Info(userRawData)
// Twitter API does not return E-Mail adresses by default. For that case special privileges have
// to be granted on a per-App basis. See https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/manage-account-settings/api-reference/get-account-verify_credentials
// Currently Twitter API only provides the full name of a user. To fill givenName and familyName
// the full name will be split at the first whitespace. This approach will not be valid for all name combinations
nameArr := strings.SplitAfterN(userRawData["name"].(string), " ", 2)
firstName := nameArr[0]
lastName := ""
if len(nameArr) == 2 {
lastName = nameArr[1]
}
nickname := userRawData["username"].(string)
profilePicture := userRawData["profile_image_url"].(string)
user = models.User{
GivenName: &firstName,
FamilyName: &lastName,
Picture: &profilePicture,
Nickname: &nickname,
}
return user, nil
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/authorizerdev/authorizer/server/memorystore"
"github.com/authorizerdev/authorizer/server/oauth"
"github.com/authorizerdev/authorizer/server/parsers"
"github.com/authorizerdev/authorizer/server/utils"
"github.com/authorizerdev/authorizer/server/validators"
)
@@ -95,7 +96,7 @@ func OAuthLoginHandler() gin.HandlerFunc {
}
oauthStateString := state + "___" + redirectURI + "___" + roles + "___" + strings.Join(scope, ",")
oauthStateString := state + "___" + redirectURI + "___" + roles + "___" + strings.Join(scope, " ")
provider := c.Param("oauth_provider")
isProviderConfigured := true
@@ -169,6 +170,26 @@ func OAuthLoginHandler() gin.HandlerFunc {
oauth.OAuthProviders.LinkedInConfig.RedirectURL = hostname + "/oauth_callback/" + constants.AuthRecipeMethodLinkedIn
url := oauth.OAuthProviders.LinkedInConfig.AuthCodeURL(oauthStateString)
c.Redirect(http.StatusTemporaryRedirect, url)
case constants.AuthRecipeMethodTwitter:
if oauth.OAuthProviders.TwitterConfig == nil {
log.Debug("Twitter OAuth provider is not configured")
isProviderConfigured = false
break
}
verifier, challenge := utils.GenerateCodeChallenge()
err := memorystore.Provider.SetState(oauthStateString, verifier)
if err != nil {
log.Debug("Error setting state: ", err)
c.JSON(500, gin.H{
"error": "internal server error",
})
return
}
oauth.OAuthProviders.TwitterConfig.RedirectURL = hostname + "/oauth_callback/" + constants.AuthRecipeMethodTwitter
url := oauth.OAuthProviders.TwitterConfig.AuthCodeURL(oauthStateString, oauth2.SetAuthURLParam("code_challenge", challenge), oauth2.SetAuthURLParam("code_challenge_method", "S256"))
c.Redirect(http.StatusTemporaryRedirect, url)
case constants.AuthRecipeMethodApple:
if oauth.OAuthProviders.AppleConfig == nil {
log.Debug("Apple OAuth provider is not configured")

View File

@@ -76,7 +76,6 @@ func TokenHandler() gin.HandlerFunc {
sessionKey := ""
if isAuthorizationCodeGrant {
if codeVerifier == "" {
log.Debug("Code verifier is empty")
gc.JSON(http.StatusBadRequest, gin.H{
@@ -134,15 +133,18 @@ func TokenHandler() gin.HandlerFunc {
})
return
}
userID = claims.Subject
roles = claims.Roles
scope = claims.Scope
loginMethod = claims.LoginMethod
// rollover the session for security
sessionKey = userID
if loginMethod != "" {
sessionKey = loginMethod + ":" + userID
}
go memorystore.Provider.DeleteUserSession(sessionKey, claims.Nonce)
} else {
// validate refresh token

View File

@@ -109,6 +109,7 @@ func main() {
router := routes.InitRouter(log)
log.Info("Starting Authorizer: ", VERSION)
port, err := memorystore.Provider.GetStringStoreEnvVariable(constants.EnvKeyPort)
log.Info("Authorizer running at PORT: ", port)
if err != nil {
log.Info("Error while getting port from env using default port 8080: ", err)
port = "8080"

View File

@@ -7,7 +7,7 @@ import (
"github.com/authorizerdev/authorizer/server/constants"
)
// SetUserSession sets the user session
// SetUserSession sets the user session for given user identifier in form recipe:user_id
func (c *provider) SetUserSession(userId, key, token string) error {
c.sessionStore.Set(userId, key, token)
return nil
@@ -34,6 +34,7 @@ func (c *provider) DeleteAllUserSessions(userId string) error {
constants.AuthRecipeMethodGithub,
constants.AuthRecipeMethodGoogle,
constants.AuthRecipeMethodLinkedIn,
constants.AuthRecipeMethodTwitter,
}
for _, namespace := range namespaces {

View File

@@ -2,7 +2,7 @@ package providers
// Provider defines current memory store provider
type Provider interface {
// SetUserSession sets the user session
// SetUserSession sets the user session for given user identifier in form recipe:user_id
SetUserSession(userId, key, token string) error
// GetAllUserSessions returns all the user sessions from the session store
GetAllUserSessions(userId string) (map[string]string, error)

View File

@@ -14,7 +14,7 @@ var (
envStorePrefix = "authorizer_env"
)
// SetUserSession sets the user session in redis store.
// SetUserSession sets the user session for given user identifier in form recipe:user_id
func (c *provider) SetUserSession(userId, key, token string) error {
err := c.store.HSet(c.ctx, userId, key, token).Err()
if err != nil {
@@ -71,6 +71,7 @@ func (c *provider) DeleteAllUserSessions(userID string) error {
constants.AuthRecipeMethodGithub,
constants.AuthRecipeMethodGoogle,
constants.AuthRecipeMethodLinkedIn,
constants.AuthRecipeMethodTwitter,
}
for _, namespace := range namespaces {
err := c.store.Del(c.ctx, namespace+":"+userID).Err()

View File

@@ -20,6 +20,7 @@ type OAuthProvider struct {
FacebookConfig *oauth2.Config
LinkedInConfig *oauth2.Config
AppleConfig *oauth2.Config
TwitterConfig *oauth2.Config
}
// OIDCProviders is a struct that contains reference all the OpenID providers
@@ -74,6 +75,7 @@ func InitOAuth() error {
ClientSecret: githubClientSecret,
RedirectURL: "/oauth_callback/github",
Endpoint: githubOAuth2.Endpoint,
Scopes: []string{"read:user", "user:email"},
}
}
@@ -133,5 +135,28 @@ func InitOAuth() error {
}
}
twitterClientID, err := memorystore.Provider.GetStringStoreEnvVariable(constants.EnvKeyTwitterClientID)
if err != nil {
twitterClientID = ""
}
twitterClientSecret, err := memorystore.Provider.GetStringStoreEnvVariable(constants.EnvKeyTwitterClientSecret)
if err != nil {
twitterClientSecret = ""
}
if twitterClientID != "" && twitterClientSecret != "" {
OAuthProviders.TwitterConfig = &oauth2.Config{
ClientID: twitterClientID,
ClientSecret: twitterClientSecret,
RedirectURL: "/oauth_callback/twitter",
Endpoint: oauth2.Endpoint{
// Endpoint is currently not yet part of oauth2-package. See https://go-review.googlesource.com/c/oauth2/+/350889 for status
AuthURL: "https://twitter.com/i/oauth2/authorize",
TokenURL: "https://api.twitter.com/2/oauth2/token",
AuthStyle: oauth2.AuthStyleInHeader,
},
Scopes: []string{"tweet.read", "users.read"},
}
}
return nil
}

View File

@@ -143,6 +143,13 @@ func EnvResolver(ctx context.Context) (*model.Env, error) {
if val, ok := store[constants.EnvKeyAppleClientSecret]; ok {
res.AppleClientSecret = refs.NewStringRef(val.(string))
}
if val, ok := store[constants.EnvKeyTwitterClientID]; ok {
res.TwitterClientID = refs.NewStringRef(val.(string))
}
if val, ok := store[constants.EnvKeyTwitterClientSecret]; ok {
res.TwitterClientSecret = refs.NewStringRef(val.(string))
}
if val, ok := store[constants.EnvKeyOrganizationName]; ok {
res.OrganizationName = refs.NewStringRef(val.(string))
}

View File

@@ -50,7 +50,7 @@ func LoginResolver(ctx context.Context, params model.LoginInput) (*model.AuthRes
user, err := db.Provider.GetUserByEmail(ctx, params.Email)
if err != nil {
log.Debug("Failed to get user by email: ", err)
return res, fmt.Errorf(`user with this email not found`)
return res, fmt.Errorf(`bad user credentials`)
}
if user.RevokedTimestamp != nil {
@@ -72,7 +72,7 @@ func LoginResolver(ctx context.Context, params model.LoginInput) (*model.AuthRes
if err != nil {
log.Debug("Failed to compare password: ", err)
return res, fmt.Errorf(`invalid password`)
return res, fmt.Errorf(`bad user credentials`)
}
defaultRolesString, err := memorystore.Provider.GetStringStoreEnvVariable(constants.EnvKeyDefaultRoles)

View File

@@ -77,6 +77,18 @@ func MetaResolver(ctx context.Context) (*model.Meta, error) {
githubClientSecret = ""
}
twitterClientID, err := memorystore.Provider.GetStringStoreEnvVariable(constants.EnvKeyTwitterClientID)
if err != nil {
log.Debug("Failed to get Twitter Client ID from environment variable", err)
twitterClientID = ""
}
twitterClientSecret, err := memorystore.Provider.GetStringStoreEnvVariable(constants.EnvKeyTwitterClientSecret)
if err != nil {
log.Debug("Failed to get Twitter Client Secret from environment variable", err)
twitterClientSecret = ""
}
isBasicAuthDisabled, err := memorystore.Provider.GetBoolStoreEnvVariable(constants.EnvKeyDisableBasicAuthentication)
if err != nil {
log.Debug("Failed to get Disable Basic Authentication from environment variable", err)
@@ -121,6 +133,7 @@ func MetaResolver(ctx context.Context) (*model.Meta, error) {
IsFacebookLoginEnabled: facebookClientID != "" && facebookClientSecret != "",
IsLinkedinLoginEnabled: linkedClientID != "" && linkedInClientSecret != "",
IsAppleLoginEnabled: appleClientID != "" && appleClientSecret != "",
IsTwitterLoginEnabled: twitterClientID != "" && twitterClientSecret != "",
IsBasicAuthenticationEnabled: !isBasicAuthDisabled,
IsEmailVerificationEnabled: !isEmailVerificationDisabled,
IsMagicLinkLoginEnabled: !isMagicLinkLoginDisabled,

View File

@@ -31,6 +31,7 @@ func clearSessionIfRequired(currentData, updatedData map[string]interface{}) {
isCurrentGoogleLoginEnabled := currentData[constants.EnvKeyGoogleClientID] != nil && currentData[constants.EnvKeyGoogleClientSecret] != nil && currentData[constants.EnvKeyGoogleClientID].(string) != "" && currentData[constants.EnvKeyGoogleClientSecret].(string) != ""
isCurrentGithubLoginEnabled := currentData[constants.EnvKeyGithubClientID] != nil && currentData[constants.EnvKeyGithubClientSecret] != nil && currentData[constants.EnvKeyGithubClientID].(string) != "" && currentData[constants.EnvKeyGithubClientSecret].(string) != ""
isCurrentLinkedInLoginEnabled := currentData[constants.EnvKeyLinkedInClientID] != nil && currentData[constants.EnvKeyLinkedInClientSecret] != nil && currentData[constants.EnvKeyLinkedInClientID].(string) != "" && currentData[constants.EnvKeyLinkedInClientSecret].(string) != ""
isCurrentTwitterLoginEnabled := currentData[constants.EnvKeyTwitterClientID] != nil && currentData[constants.EnvKeyTwitterClientSecret] != nil && currentData[constants.EnvKeyTwitterClientID].(string) != "" && currentData[constants.EnvKeyTwitterClientSecret].(string) != ""
isUpdatedBasicAuthEnabled := !updatedData[constants.EnvKeyDisableBasicAuthentication].(bool)
isUpdatedMagicLinkLoginEnabled := !updatedData[constants.EnvKeyDisableMagicLinkLogin].(bool)
@@ -39,6 +40,7 @@ func clearSessionIfRequired(currentData, updatedData map[string]interface{}) {
isUpdatedGoogleLoginEnabled := updatedData[constants.EnvKeyGoogleClientID] != nil && updatedData[constants.EnvKeyGoogleClientSecret] != nil && updatedData[constants.EnvKeyGoogleClientID].(string) != "" && updatedData[constants.EnvKeyGoogleClientSecret].(string) != ""
isUpdatedGithubLoginEnabled := updatedData[constants.EnvKeyGithubClientID] != nil && updatedData[constants.EnvKeyGithubClientSecret] != nil && updatedData[constants.EnvKeyGithubClientID].(string) != "" && updatedData[constants.EnvKeyGithubClientSecret].(string) != ""
isUpdatedLinkedInLoginEnabled := updatedData[constants.EnvKeyLinkedInClientID] != nil && updatedData[constants.EnvKeyLinkedInClientSecret] != nil && updatedData[constants.EnvKeyLinkedInClientID].(string) != "" && updatedData[constants.EnvKeyLinkedInClientSecret].(string) != ""
isUpdatedTwitterLoginEnabled := updatedData[constants.EnvKeyTwitterClientID] != nil && updatedData[constants.EnvKeyTwitterClientSecret] != nil && updatedData[constants.EnvKeyTwitterClientID].(string) != "" && updatedData[constants.EnvKeyTwitterClientSecret].(string) != ""
if isCurrentBasicAuthEnabled && !isUpdatedBasicAuthEnabled {
memorystore.Provider.DeleteSessionForNamespace(constants.AuthRecipeMethodBasicAuth)
@@ -67,6 +69,10 @@ func clearSessionIfRequired(currentData, updatedData map[string]interface{}) {
if isCurrentLinkedInLoginEnabled && !isUpdatedLinkedInLoginEnabled {
memorystore.Provider.DeleteSessionForNamespace(constants.AuthRecipeMethodLinkedIn)
}
if isCurrentTwitterLoginEnabled && !isUpdatedTwitterLoginEnabled {
memorystore.Provider.DeleteSessionForNamespace(constants.AuthRecipeMethodTwitter)
}
}
// UpdateEnvResolver is a resolver for update config mutation

View File

@@ -298,7 +298,6 @@ func ValidateBrowserSession(gc *gin.Context, encryptedSession string) (*SessionD
if res.LoginMethod != "" {
sessionStoreKey = res.LoginMethod + ":" + res.Subject
}
token, err := memorystore.Provider.GetUserSession(sessionStoreKey, constants.TokenTypeSessionToken+"_"+res.Nonce)
if token == "" || err != nil {
log.Debug("invalid browser session:", err)

32
server/utils/pkce.go Normal file
View File

@@ -0,0 +1,32 @@
package utils
import (
"crypto/sha256"
b64 "encoding/base64"
"math/rand"
"strings"
"time"
)
const (
length = 32
)
// GenerateCodeChallenge creates PKCE-Code-Challenge
// and returns the verifier and challenge
func GenerateCodeChallenge() (string, string) {
// Generate Verifier
randGenerator := rand.New(rand.NewSource(time.Now().UnixNano()))
randomBytes := make([]byte, length)
for i := 0; i < length; i++ {
randomBytes[i] = byte(randGenerator.Intn(255))
}
verifier := strings.Trim(b64.URLEncoding.EncodeToString(randomBytes), "=")
// Generate Challenge
rawChallenge := sha256.New()
rawChallenge.Write([]byte(verifier))
challenge := strings.Trim(b64.URLEncoding.EncodeToString(rawChallenge.Sum(nil)), "=")
return verifier, challenge
}

View File

@@ -30,8 +30,8 @@ func IsValidOrigin(url string) bool {
replacedString := origin
// if has regex whitelisted domains
if strings.Contains(origin, "*") {
replacedString = strings.Replace(origin, ".", "\\.", -1)
replacedString = strings.Replace(replacedString, "*", ".*", -1)
replacedString = strings.ReplaceAll(origin, ".", "\\.")
replacedString = strings.ReplaceAll(replacedString, "*", ".*")
if strings.HasPrefix(replacedString, ".*") {
replacedString += "\\b"