Skip to content

Commit c04f062

Browse files
committed
feat: integreate OKTA authentication
1 parent 5f18b8f commit c04f062

File tree

10 files changed

+455
-106
lines changed

10 files changed

+455
-106
lines changed

cli-typescript/package-lock.json

Lines changed: 111 additions & 60 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli-typescript/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"devDependencies": {
2727
"@esbuild-kit/esm-loader": "^2.6.5",
2828
"@types/eslint-plugin-prettier": "^3.1.3",
29+
"@types/express": "^5.0.2",
2930
"@types/node": "^22.13.16",
3031
"eslint": "^9.24.0",
3132
"eslint-config-prettier": "^10.1.1",
@@ -41,6 +42,8 @@
4142
"commander": "^13.1.0",
4243
"dotenv": "^16.4.7",
4344
"ethers": "^6.13.5",
45+
"express": "^5.1.0",
46+
"mkdirp": "^3.0.1",
4447
"ox": "^0.7.0",
4548
"viem": "^2.26.3"
4649
}

cli-typescript/src/cmds/createCommand.ts

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,13 @@ export const getNewProjectCmdDescription = (defaultInfo?: string) => {
5555
`;
5656
};
5757

58-
const presets = async () => {
58+
const presets = async (cliCmd: string) => {
5959
try {
6060
console.log('Starting prestart tasks...');
6161

62+
// set cmd name globally
63+
process.env.MAGICDROP_CLI_CMD = cliCmd;
64+
6265
setBaseDir();
6366
} catch (error: any) {
6467
showError({ text: `An error occurred: ${error.message}` });
@@ -80,14 +83,32 @@ export const createEvmCommand = ({
8083
.description(`${platform.name} launchpad commands`)
8184
.aliases(commandAliases);
8285

83-
newCmd.hook('preAction', async () => {
86+
newCmd.hook('preAction', async (_, actionCommand) => {
8487
try {
85-
await presets();
88+
await presets(actionCommand.name());
8689
} catch (error: any) {
8790
showError({ text: `setup failed - ${error.message}` });
8891
}
8992
});
9093

94+
// subcommand hook; verify if the collection is supported on the platform
95+
newCmd.hook('preAction', (_, actionCommand) => {
96+
const symbol = actionCommand.args[0];
97+
98+
if (!SUBCOMMAND_EXCLUDE_LIST.includes(actionCommand.name()) && !!symbol) {
99+
const store = getProjectStore(symbol);
100+
store.read();
101+
102+
if (!platform.isChainIdSupported(store.data?.chainId ?? 0)) {
103+
showError({
104+
text: `collection '${symbol}' not supported on the ${platform.name} platform.`,
105+
});
106+
107+
process.exit(1);
108+
}
109+
}
110+
});
111+
91112
newCmd
92113
.command('new <symbol>')
93114
.aliases(['n', 'init'])
@@ -160,23 +181,6 @@ export const createEvmCommand = ({
160181
) => await deployAction(platform, symbol, params),
161182
);
162183

163-
// subcommand hook; verify if the collection is supported on the platform
164-
newCmd.hook('preAction', (_, actionCommand) => {
165-
const symbol = actionCommand.args[0];
166-
if (!SUBCOMMAND_EXCLUDE_LIST.includes(actionCommand.name()) || !!symbol) {
167-
const store = getProjectStore(symbol);
168-
store.read();
169-
170-
if (!platform.isChainIdSupported(store.data?.chainId ?? 0)) {
171-
showError({
172-
text: `collection '${symbol}' not supported on the ${platform.name} platform.`,
173-
});
174-
175-
process.exit(1);
176-
}
177-
}
178-
});
179-
180184
newCmd
181185
.command('configure-project <symbol>')
182186
.description(

cli-typescript/src/utils/ContractManager.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ export class ContractManager {
4040
public client: PublicClient;
4141
public rpcUrl: string;
4242
public chain: Chain;
43-
private meTurnkeServiceClient: ReturnType<typeof getMETurnkeyServiceClient>;
4443

4544
constructor(
4645
public chainId: SUPPORTED_CHAINS,
@@ -49,7 +48,6 @@ export class ContractManager {
4948
) {
5049
this.rpcUrl = rpcUrls[this.chainId];
5150
this.chain = getViemChainByChainId(this.chainId);
52-
this.meTurnkeServiceClient = getMETurnkeyServiceClient();
5351

5452
// Initialize viem client
5553
this.client = createPublicClient({
@@ -114,7 +112,8 @@ export class ContractManager {
114112
value?: bigint;
115113
gasLimit?: bigint;
116114
}): Promise<Hex> {
117-
return this.meTurnkeServiceClient.sendTransaction(this.symbol, {
115+
const meTurnkeyServiceClient = await getMETurnkeyServiceClient();
116+
return await meTurnkeyServiceClient.sendTransaction(this.symbol, {
118117
to,
119118
data,
120119
value,
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import fs from 'fs';
2+
import * as okta from './okta';
3+
import { mkdirp } from 'mkdirp';
4+
import { COLLECTION_DIR } from '../constants';
5+
6+
export const authenticate = async () => {
7+
console.log('Authenticating...');
8+
const configDir = `${COLLECTION_DIR}/.config/auth`;
9+
const configPath = `${configDir}/config.json`;
10+
11+
// make dir if it does not exist
12+
mkdirp.sync(configDir);
13+
14+
// Load credentials
15+
const config: IConfig = fs.existsSync(configPath)
16+
? JSON.parse(fs.readFileSync(configPath, 'utf-8'))
17+
: {
18+
accessToken: '',
19+
refreshToken: '',
20+
idToken: '',
21+
expires: '2025-05-31T00:00:00.000Z',
22+
meApiAdminKey: '',
23+
};
24+
25+
const setToken = (token: okta.IAuth['token']) => {
26+
config.accessToken = token.access_token;
27+
config.refreshToken = token.refresh_token;
28+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
29+
};
30+
31+
if (!config.accessToken || !config.refreshToken) {
32+
const { token } = await okta.authenticate();
33+
setToken(token);
34+
return;
35+
}
36+
37+
// Decode the access token and find expiry
38+
const decoded = decodeJWT(config.accessToken);
39+
const expiry = new Date(decoded.exp * 1000).getTime();
40+
const now = new Date().getTime();
41+
42+
if (now >= expiry) {
43+
// Authenticate if expired, and we do not have a valid refresh token
44+
if (!(await okta.validToken(config.refreshToken, 'refresh_token'))) {
45+
const { token } = await okta.authenticate();
46+
setToken(token);
47+
} else {
48+
// Refresh if expired, and we have a valid refresh token
49+
const token = await okta.refresh(config.refreshToken);
50+
if (token) setToken(token);
51+
}
52+
}
53+
54+
return {
55+
Authorization: `Bearer ${config.accessToken}`,
56+
'x-bypass-bot-key': 'FDRifjUqk8RH3SRCyRw',
57+
};
58+
};
59+
60+
export const decodeJWT = (token: string) => {
61+
return JSON.parse(
62+
Buffer.from(token.split('.')[1], 'base64').toString(),
63+
) as DecodedJWT;
64+
};
65+
66+
export type DecodedJWT = {
67+
ver: number;
68+
jti: string;
69+
iss: string;
70+
aud: string;
71+
sub: string;
72+
iat: number;
73+
exp: number;
74+
cid: string;
75+
uid: string;
76+
scp: string[];
77+
auth_time: number;
78+
};
79+
80+
export type IConfig = {
81+
accessToken: string;
82+
refreshToken: string;
83+
idToken: string;
84+
expires: string;
85+
meApiAdminKey: string;
86+
xBypassBotKey: string;
87+
};
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/**
2+
* Authenticate using okta
3+
*
4+
* TODO: we forked this file from cli/ to unblock eth tooling. Refactor this into a shared lib.
5+
*/
6+
7+
import axios from 'axios';
8+
import { createHash, randomBytes } from 'crypto';
9+
import express from 'express';
10+
import open from 'open';
11+
import { stringify } from 'querystring';
12+
13+
export const OKTA = {
14+
oktaOrgUrl: 'https://magiceden.okta.com',
15+
clientId: '0oaczwrqrdhJTKiuC696',
16+
scopes: 'openid email profile offline_access',
17+
};
18+
19+
const redirectUri = 'http://localhost:30033/login/callback';
20+
21+
/**
22+
* Okta PKCE config
23+
*/
24+
export const getInfo = async (accessToken: string): Promise<boolean> => {
25+
const client = axios.create({
26+
baseURL: OKTA.oktaOrgUrl,
27+
});
28+
29+
const info = await client
30+
.post(
31+
'/oauth2/v1/introspect',
32+
stringify({
33+
token: accessToken,
34+
client_id: OKTA.clientId,
35+
token_type_hint: 'access_token',
36+
}),
37+
)
38+
.then((res) => res.data);
39+
40+
return info;
41+
};
42+
43+
export const validToken = async (
44+
token: string,
45+
tokenType: 'access_token' | 'refresh_token',
46+
): Promise<boolean> => {
47+
const client = axios.create({
48+
baseURL: OKTA.oktaOrgUrl,
49+
});
50+
51+
const info = await client
52+
.post<IAuth['info']>(
53+
'/oauth2/v1/introspect',
54+
stringify({
55+
token: token,
56+
client_id: OKTA.clientId,
57+
token_type_hint: tokenType,
58+
}),
59+
)
60+
.then((res) => res.data);
61+
62+
return info!.active;
63+
};
64+
65+
export const refresh = async (
66+
refreshToken: string,
67+
): Promise<IAuth['refresh']> => {
68+
const client = axios.create({
69+
baseURL: OKTA.oktaOrgUrl,
70+
});
71+
72+
const token = await client
73+
.post(
74+
'/oauth2/v1/token',
75+
stringify({
76+
grant_type: 'refresh_token',
77+
redirect_uri: redirectUri,
78+
scope: OKTA.scopes,
79+
client_id: OKTA.clientId,
80+
refresh_token: refreshToken,
81+
}),
82+
)
83+
.then((res) => res.data);
84+
85+
return token;
86+
};
87+
88+
export const authenticate = async (): Promise<IAuth> => {
89+
const verifier = randomBytes(64).toString('base64url');
90+
const verifierSha = base64url(
91+
createHash('sha256').update(verifier).digest('base64'),
92+
);
93+
94+
const code: string = await new Promise((resolve, reject) => {
95+
const server = express()
96+
.get('/login/callback', (req, res) => {
97+
try {
98+
resolve(req.query.code as string);
99+
res.json({ message: 'Thank you. You can close this tab.' });
100+
} catch (err) {
101+
reject(err);
102+
} finally {
103+
server.close();
104+
}
105+
})
106+
.listen(30033);
107+
108+
open(
109+
`${OKTA.oktaOrgUrl}/oauth2/v1/authorize?${stringify({
110+
client_id: OKTA.clientId,
111+
response_type: 'code',
112+
scope: OKTA.scopes,
113+
redirect_uri: redirectUri,
114+
state: randomBytes(32).toString('base64url'),
115+
code_challenge_method: 'S256',
116+
code_challenge: verifierSha,
117+
})}`,
118+
);
119+
});
120+
121+
const client = axios.create({
122+
baseURL: OKTA.oktaOrgUrl,
123+
});
124+
125+
const token = (await client
126+
.post<{ access_token: string }>(
127+
'/oauth2/v1/token',
128+
stringify({
129+
grant_type: 'authorization_code',
130+
redirect_uri: redirectUri,
131+
client_id: OKTA.clientId,
132+
code,
133+
code_verifier: verifier,
134+
}),
135+
)
136+
.then((res) => res.data)) as IAuth['token'];
137+
138+
return { code, token } as unknown as IAuth;
139+
};
140+
141+
const base64url = (str: string) =>
142+
str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
143+
144+
export type IAuth = {
145+
code: string;
146+
147+
token: {
148+
token_type: string;
149+
expires_in: number;
150+
access_token: string;
151+
scope: string;
152+
refresh_token: string;
153+
id_token: string;
154+
};
155+
156+
user?: {
157+
sub: string;
158+
name: string;
159+
locale: string;
160+
email: string;
161+
preferred_username: string;
162+
given_name: string;
163+
family_name: string;
164+
zoneinfo: string;
165+
updated_at: number;
166+
email_verified: boolean;
167+
};
168+
169+
info?: {
170+
active: boolean;
171+
scope: string;
172+
username: string;
173+
exp: number;
174+
iat: number;
175+
sub: string;
176+
aud: string;
177+
iss: string;
178+
jti: string;
179+
token_type: string;
180+
client_id: string;
181+
uid: string;
182+
};
183+
184+
refresh?: {
185+
token_type: string;
186+
expires_in: number;
187+
access_token: string;
188+
scope: string;
189+
refresh_token: string;
190+
id_token: string;
191+
};
192+
};

0 commit comments

Comments
 (0)