@linktr.ee/create-link-app 2.2.4 → 2.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,9 +7,13 @@ const core_1 = require("@oclif/core");
7
7
  const axios_1 = __importDefault(require("axios"));
8
8
  const fs_1 = __importDefault(require("fs"));
9
9
  const path_1 = __importDefault(require("path"));
10
+ const picocolors_1 = __importDefault(require("picocolors"));
10
11
  const slugify_1 = __importDefault(require("slugify"));
11
12
  const base_1 = __importDefault(require("../base"));
12
- const access_token_1 = require("../lib/auth/access-token");
13
+ const inline_login_1 = require("../lib/auth/inline-login");
14
+ const pkce_flow_1 = require("../lib/auth/pkce-flow");
15
+ const token_storage_1 = require("../lib/auth/token-storage");
16
+ const write_line_1 = require("../lib/utils/write-line");
13
17
  const check_link_app_exists_1 = __importDefault(require("../lib/deploy/check-link-app-exists"));
14
18
  const create_form_data_1 = __importDefault(require("../lib/deploy/create-form-data"));
15
19
  const pack_project_1 = __importDefault(require("../lib/deploy/pack-project"));
@@ -18,7 +22,21 @@ class Deploy extends base_1.default {
18
22
  async run() {
19
23
  const { flags } = await this.parse(Deploy);
20
24
  const appConfig = await this.getAppConfig(flags.qa ? 'qa' : 'production');
21
- const accessToken = (0, access_token_1.getAccessToken)(appConfig.auth.audience);
25
+ let accessToken = (0, token_storage_1.getToken)(appConfig.auth.audience);
26
+ if (!accessToken) {
27
+ (0, write_line_1.writeLine)(picocolors_1.default.yellow('Not authenticated or token expired. Starting login...'));
28
+ (0, write_line_1.writeLine)();
29
+ try {
30
+ accessToken = await (0, inline_login_1.performLogin)(appConfig);
31
+ }
32
+ catch (error) {
33
+ if (error instanceof pkce_flow_1.LoginCancelledError) {
34
+ this.log('❌ Deployment cancelled — login was not completed.');
35
+ return;
36
+ }
37
+ throw error;
38
+ }
39
+ }
22
40
  const linkTypeServiceUrl = `${flags.endpoint ?? appConfig.link_types_url}/link-types`;
23
41
  const isForceUpdate = !!flags['force-update'];
24
42
  const isQa = !!flags.qa;
@@ -6,13 +6,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const core_1 = require("@oclif/core");
7
7
  const axios_1 = __importDefault(require("axios"));
8
8
  const base_1 = __importDefault(require("../base"));
9
- const access_token_1 = require("../lib/auth/access-token");
9
+ const token_storage_1 = require("../lib/auth/token-storage");
10
10
  class GrantAccess extends base_1.default {
11
11
  async run() {
12
12
  const { args, flags } = await this.parse(GrantAccess);
13
13
  this.log(`🔐 Granting access to Link App '${args.link_app_id}' for user '${args.username}'...`);
14
14
  const appConfig = await this.getAppConfig(flags.qa ? 'qa' : 'production');
15
- const accessToken = (0, access_token_1.getAccessToken)(appConfig.auth.audience);
15
+ const accessToken = (0, token_storage_1.getToken)(appConfig.auth.audience);
16
+ if (!accessToken) {
17
+ this.error('Not logged in. Please login first using: create-link-app login');
18
+ }
16
19
  const url = `${flags.endpoint ?? appConfig.link_types_url}/link-types/${args.link_app_id}/maintainers`;
17
20
  const maintainerDto = {
18
21
  username: args.username,
@@ -29,94 +29,49 @@ Object.defineProperty(exports, "__esModule", { value: true });
29
29
  exports.loginCommand = void 0;
30
30
  const core_1 = require("@oclif/core");
31
31
  const p = __importStar(require("@clack/prompts"));
32
- const axios_1 = __importDefault(require("axios"));
33
- const open_1 = __importDefault(require("open"));
34
32
  const picocolors_1 = __importDefault(require("picocolors"));
35
33
  const base_1 = __importDefault(require("../base"));
36
34
  const fetch_app_config_1 = __importDefault(require("../lib/fetch-app-config"));
37
- const device_auth_1 = require("../lib/auth/device-auth");
38
- const access_token_1 = require("../lib/auth/access-token");
35
+ const inline_login_1 = require("../lib/auth/inline-login");
36
+ const pkce_flow_1 = require("../lib/auth/pkce-flow");
37
+ const token_storage_1 = require("../lib/auth/token-storage");
39
38
  const write_line_1 = require("../lib/utils/write-line");
40
- function fixVerificationUrl(url, correctProjectId) {
41
- try {
42
- const parsed = new URL(url);
43
- parsed.pathname = parsed.pathname.replace(/^\/login\/[A-Za-z0-9]+/, `/login/${correctProjectId}`);
44
- return parsed.toString();
45
- }
46
- catch {
47
- return url;
48
- }
49
- }
50
- async function getDescopeHostedConfigStatus(projectId) {
51
- const hostedConfigUrl = `https://api.descope.com/pages/${projectId}/v2-beta/config.json`;
52
- try {
53
- const response = await axios_1.default.get(hostedConfigUrl, {
54
- validateStatus: () => true,
55
- });
56
- return response.status;
57
- }
58
- catch {
59
- return null;
60
- }
61
- }
62
39
  async function loginCommand(options) {
63
40
  const env = options.qa ? 'qa' : 'production';
64
- p.intro(picocolors_1.default.cyan('Login to Linktree'));
65
- const s = p.spinner();
66
- s.start('Fetching configuration');
67
- const config = await (0, fetch_app_config_1.default)(env);
68
- s.stop('Configuration loaded');
69
- // Initiate device authorization
70
- s.start('Starting device authorization');
71
- const handle = await (0, device_auth_1.initDeviceAuthorization)(config.auth);
72
- s.stop('Device authorization initiated');
73
- const { expires_in, user_code, verification_uri, verification_uri_complete } = handle;
74
- const expiryMinutes = Math.floor(expires_in / 60);
75
- // The device authorization response may contain a project ID in the URL path that differs
76
- // from config.auth.project_id (e.g. when overridden via env var or hardcoded fallback).
77
- // Replace it so the browser opens the correct hosted login flow.
78
- const fixedUri = config.auth.project_id ? fixVerificationUrl(verification_uri, config.auth.project_id) : verification_uri;
79
- const fixedUriComplete = config.auth.project_id
80
- ? fixVerificationUrl(verification_uri_complete, config.auth.project_id)
81
- : verification_uri_complete;
41
+ const envLabel = options.qa ? 'QA' : 'Production';
42
+ (0, write_line_1.writeLine)(picocolors_1.default.cyan(`Logging in to ${envLabel}...`));
82
43
  (0, write_line_1.writeLine)();
83
- (0, write_line_1.writeLine)(`${picocolors_1.default.green('✓ Authorization code:')} ${picocolors_1.default.bold(user_code)}`);
84
- (0, write_line_1.writeLine)(picocolors_1.default.dim(` ${fixedUriComplete}`));
85
- if (fixedUri && fixedUri !== fixedUriComplete) {
86
- (0, write_line_1.writeLine)(picocolors_1.default.dim(` ${fixedUri}`));
87
- }
88
- (0, write_line_1.writeLine)(picocolors_1.default.dim(` Expires in ${expiryMinutes} minutes`));
89
- (0, write_line_1.writeLine)();
90
- // Open browser first so the user can start authenticating immediately
91
- await (0, open_1.default)(fixedUriComplete);
92
- (0, write_line_1.writeLine)(picocolors_1.default.cyan('🌐 Browser opened for authentication'));
93
- (0, write_line_1.writeLine)(picocolors_1.default.dim(' If browser did not open, visit the link above'));
94
- (0, write_line_1.writeLine)();
95
- // Fire-and-forget diagnostic check — don't block the auth flow
96
- const projectId = config.auth.project_id;
97
- if (projectId) {
98
- void getDescopeHostedConfigStatus(projectId).then((status) => {
99
- if (status && status >= 400) {
100
- (0, write_line_1.writeLine)(picocolors_1.default.yellow('⚠ Descope hosted login may be misconfigured for this project.'));
101
- (0, write_line_1.writeLine)(picocolors_1.default.dim(` https://api.descope.com/pages/${projectId}/v2-beta/config.json returned HTTP ${status}`));
102
- (0, write_line_1.writeLine)(picocolors_1.default.dim(' This usually means an outdated auth project_id or missing Descope flow hosting config.'));
103
- (0, write_line_1.writeLine)(picocolors_1.default.dim(' Ask your platform team to set auth.project_id in create-link-app.json, or set LINKAPP_DESCOPE_[QA|PRODUCTION]_PROJECT_ID.'));
44
+ try {
45
+ const config = await (0, fetch_app_config_1.default)(env);
46
+ // Check if already logged in
47
+ if ((0, token_storage_1.isTokenValid)(config.auth.audience)) {
48
+ const shouldReauth = await p.confirm({
49
+ message: 'Already logged in. Re-authenticate?',
50
+ initialValue: false,
51
+ });
52
+ if (p.isCancel(shouldReauth) || !shouldReauth) {
104
53
  (0, write_line_1.writeLine)();
54
+ (0, write_line_1.writeLine)(picocolors_1.default.dim('Login cancelled'));
55
+ return;
105
56
  }
106
- });
57
+ }
58
+ await (0, inline_login_1.performLogin)(config);
59
+ process.exit(0);
107
60
  }
108
- // Poll for token
109
- s.start('Waiting for authentication');
110
- const token = await (0, device_auth_1.pollAccessToken)(handle);
111
- s.stop('Authentication successful');
112
- if ((0, access_token_1.isTokenValid)(token)) {
113
- (0, access_token_1.saveAccessToken)(token.access_token, config.auth.audience);
114
- (0, write_line_1.writeLine)(picocolors_1.default.green('✓ Login successful'));
115
- if (token.expires_at) {
116
- (0, write_line_1.writeLine)(picocolors_1.default.dim(` Token expires at ${new Date(token.expires_at * 1000).toString()}`));
61
+ catch (error) {
62
+ if (error instanceof pkce_flow_1.LoginCancelledError) {
63
+ (0, write_line_1.writeLine)();
64
+ (0, write_line_1.writeLine)(picocolors_1.default.dim('Login cancelled'));
65
+ process.exit(0);
66
+ }
67
+ if (error instanceof Error) {
68
+ console.error(picocolors_1.default.red('\n✗ Login failed:'), error.message);
69
+ }
70
+ else {
71
+ console.error(picocolors_1.default.red('\n✗ Login failed:'), error);
117
72
  }
73
+ process.exit(1);
118
74
  }
119
- p.outro(picocolors_1.default.green('You are now logged in'));
120
75
  }
121
76
  exports.loginCommand = loginCommand;
122
77
  class Login extends base_1.default {
@@ -9,14 +9,16 @@ const open_1 = __importDefault(require("open"));
9
9
  const picocolors_1 = __importDefault(require("picocolors"));
10
10
  const base_1 = __importDefault(require("../base"));
11
11
  const fetch_app_config_1 = __importDefault(require("../lib/fetch-app-config"));
12
- const access_token_1 = require("../lib/auth/access-token");
12
+ const token_storage_1 = require("../lib/auth/token-storage");
13
13
  const write_line_1 = require("../lib/utils/write-line");
14
14
  async function logoutCommand(options) {
15
- const env = options.qa ? 'qa' : 'production';
15
+ const env = options.qa ? 'QA' : 'Production';
16
+ (0, write_line_1.writeLine)(picocolors_1.default.cyan(`🔓 Logging out from ${env}...`));
17
+ (0, write_line_1.writeLine)();
16
18
  try {
17
- const config = await (0, fetch_app_config_1.default)(env);
18
- const { audience } = config.auth;
19
- (0, access_token_1.removeAccessToken)(audience);
19
+ const config = await (0, fetch_app_config_1.default)(options.qa ? 'qa' : 'production');
20
+ // Remove stored token for the specified audience
21
+ (0, token_storage_1.removeToken)(config.auth.audience);
20
22
  (0, write_line_1.writeLine)(picocolors_1.default.green('✓ Logged out successfully'));
21
23
  (0, write_line_1.writeLine)(picocolors_1.default.dim(' Local credentials have been removed'));
22
24
  if (config.logout_redirect_url) {
@@ -30,9 +32,16 @@ async function logoutCommand(options) {
30
32
  }
31
33
  }
32
34
  (0, write_line_1.writeLine)();
35
+ process.exit(0);
33
36
  }
34
37
  catch (error) {
35
- console.error(picocolors_1.default.red('✗ Logout failed:'), error);
38
+ if (error instanceof Error) {
39
+ console.error(picocolors_1.default.red('✗ Logout failed:'), error.message);
40
+ }
41
+ else {
42
+ console.error(picocolors_1.default.red('✗ Logout failed:'), error);
43
+ }
44
+ process.exit(1);
36
45
  }
37
46
  }
38
47
  exports.logoutCommand = logoutCommand;
@@ -7,7 +7,7 @@ const core_1 = require("@oclif/core");
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const base_1 = __importDefault(require("../base"));
10
- const access_token_1 = require("../lib/auth/access-token");
10
+ const token_storage_1 = require("../lib/auth/token-storage");
11
11
  const test_url_match_rules_1 = __importDefault(require("../lib/deploy/test-url-match-rules"));
12
12
  class TestUrlMatchRules extends base_1.default {
13
13
  async run() {
@@ -15,7 +15,10 @@ class TestUrlMatchRules extends base_1.default {
15
15
  this.log(`🧪 Testing URL match rules for: ${args.url}`);
16
16
  const appConfig = await this.getAppConfig(flags.qa ? 'qa' : 'production');
17
17
  const linkTypeServiceUrl = `${flags.endpoint ?? appConfig.link_types_url}/link-types`;
18
- const accessToken = (0, access_token_1.getAccessToken)(appConfig.auth.audience);
18
+ const accessToken = (0, token_storage_1.getToken)(appConfig.auth.audience);
19
+ if (!accessToken) {
20
+ this.error('Not logged in. Please login first using: create-link-app login', { exit: 1 });
21
+ }
19
22
  const url = args.url;
20
23
  if (!url) {
21
24
  this.error('❌ URL is required', { exit: 1 });
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ var __importDefault = (this && this.__importDefault) || function (mod) {
26
+ return (mod && mod.__esModule) ? mod : { "default": mod };
27
+ };
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.performLogin = void 0;
30
+ const p = __importStar(require("@clack/prompts"));
31
+ const detect_port_1 = require("detect-port");
32
+ const open_1 = __importDefault(require("open"));
33
+ const picocolors_1 = __importDefault(require("picocolors"));
34
+ const write_line_1 = require("../utils/write-line");
35
+ const pkce_flow_1 = require("./pkce-flow");
36
+ const token_storage_1 = require("./token-storage");
37
+ /**
38
+ * Performs the PKCE login flow and returns the access token.
39
+ * Reusable from both the login command and inline during deploy.
40
+ *
41
+ * @throws Error if login fails or is cancelled
42
+ */
43
+ async function performLogin(config) {
44
+ const s = p.spinner();
45
+ const port = await (0, detect_port_1.detect)(3456);
46
+ s.start('Starting authentication');
47
+ const flow = await (0, pkce_flow_1.startPkceFlow)(config.auth, port);
48
+ s.stop('Authentication started');
49
+ const cleanup = () => {
50
+ flow.cancel();
51
+ };
52
+ process.on('SIGINT', cleanup);
53
+ process.on('SIGTERM', cleanup);
54
+ try {
55
+ (0, write_line_1.writeLine)();
56
+ (0, write_line_1.writeLine)(picocolors_1.default.dim(` ${flow.authUrl}`));
57
+ (0, write_line_1.writeLine)();
58
+ await (0, open_1.default)(flow.authUrl);
59
+ (0, write_line_1.writeLine)(picocolors_1.default.cyan(' Browser opened for authentication'));
60
+ (0, write_line_1.writeLine)(picocolors_1.default.dim(' If browser did not open, visit the link above'));
61
+ (0, write_line_1.writeLine)();
62
+ s.start('Waiting for authorization');
63
+ const tokenSet = await flow.waitForCallback();
64
+ s.stop('Authorization successful');
65
+ if (!tokenSet.access_token) {
66
+ throw new Error('No access token received');
67
+ }
68
+ const expiresAt = tokenSet.expires_at ?? (tokenSet.expires_in ? Math.floor(Date.now() / 1000) + tokenSet.expires_in : undefined);
69
+ (0, token_storage_1.saveToken)(tokenSet.access_token, config.auth.audience, expiresAt);
70
+ (0, write_line_1.writeLine)();
71
+ (0, write_line_1.writeLine)(picocolors_1.default.green(' Login successful!'));
72
+ if (expiresAt) {
73
+ (0, write_line_1.writeLine)(picocolors_1.default.dim(` Token expires: ${new Date(expiresAt * 1000).toLocaleString()}`));
74
+ }
75
+ (0, write_line_1.writeLine)();
76
+ return tokenSet.access_token;
77
+ }
78
+ catch (error) {
79
+ if (error instanceof pkce_flow_1.LoginCancelledError) {
80
+ s.stop('Login cancelled');
81
+ throw error;
82
+ }
83
+ throw error;
84
+ }
85
+ finally {
86
+ process.removeListener('SIGINT', cleanup);
87
+ process.removeListener('SIGTERM', cleanup);
88
+ }
89
+ }
90
+ exports.performLogin = performLogin;
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.startPkceFlow = exports.LoginCancelledError = void 0;
4
+ const node_http_1 = require("node:http");
5
+ const openid_client_1 = require("openid-client");
6
+ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
7
+ class LoginCancelledError extends Error {
8
+ constructor() {
9
+ super('Login cancelled');
10
+ this.name = 'LoginCancelledError';
11
+ }
12
+ }
13
+ exports.LoginCancelledError = LoginCancelledError;
14
+ function successHtml() {
15
+ return `<!DOCTYPE html><html><head><title>Login Successful</title>
16
+ <style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f0fdf4}
17
+ .card{text-align:center;padding:2rem;border-radius:8px;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.1)}
18
+ h1{color:#16a34a;margin-bottom:0.5rem}p{color:#6b7280}</style></head>
19
+ <body><div class="card"><h1>Login Successful</h1><p>You can close this window and return to the terminal.</p></div></body></html>`;
20
+ }
21
+ function errorHtml(message) {
22
+ const escaped = message.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
23
+ return `<!DOCTYPE html><html><head><title>Login Failed</title>
24
+ <style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#fef2f2}
25
+ .card{text-align:center;padding:2rem;border-radius:8px;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.1)}
26
+ h1{color:#dc2626;margin-bottom:0.5rem}p{color:#6b7280}</style></head>
27
+ <body><div class="card"><h1>Login Failed</h1><p>${escaped}</p><p>Please close this tab and try again.</p></div></body></html>`;
28
+ }
29
+ function startPkceFlow(config, port) {
30
+ const { domain, client_id, audience, project_id } = config;
31
+ if (!project_id) {
32
+ return Promise.reject(new Error('Authentication configuration is missing the project_id. Please update to the latest CLI version and try again.'));
33
+ }
34
+ const issuer = new openid_client_1.Issuer({
35
+ issuer: `${domain}/v1/apps/customized/${project_id}`,
36
+ authorization_endpoint: `${domain}/oauth2/v1/apps/${project_id}/authorize`,
37
+ token_endpoint: `${domain}/oauth2/v1/apps/${project_id}/token`,
38
+ jwks_uri: `${domain}/${project_id}/.well-known/jwks.json`,
39
+ });
40
+ const client = new issuer.Client({
41
+ client_id,
42
+ token_endpoint_auth_method: 'none',
43
+ });
44
+ const codeVerifier = openid_client_1.generators.codeVerifier();
45
+ const codeChallenge = openid_client_1.generators.codeChallenge(codeVerifier);
46
+ const state = openid_client_1.generators.state();
47
+ const redirectUri = `http://localhost:${port}/callback`;
48
+ const authUrl = client.authorizationUrl({
49
+ scope: 'openid',
50
+ audience,
51
+ code_challenge: codeChallenge,
52
+ code_challenge_method: 'S256',
53
+ state,
54
+ redirect_uri: redirectUri,
55
+ response_type: 'code',
56
+ });
57
+ let settled = false;
58
+ let resolveCallback;
59
+ let rejectCallback;
60
+ let timeoutId;
61
+ const callbackPromise = new Promise((resolve, reject) => {
62
+ resolveCallback = resolve;
63
+ rejectCallback = reject;
64
+ });
65
+ const server = (0, node_http_1.createServer)(async (req, res) => {
66
+ try {
67
+ const url = new URL(req.url ?? '/', `http://localhost:${port}`);
68
+ if (url.pathname !== '/callback') {
69
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
70
+ res.end('Not found');
71
+ return;
72
+ }
73
+ if (settled) {
74
+ res.writeHead(200, { 'Content-Type': 'text/html' });
75
+ res.end(successHtml());
76
+ return;
77
+ }
78
+ const params = client.callbackParams(req);
79
+ const tokenSet = await client.callback(redirectUri, params, {
80
+ code_verifier: codeVerifier,
81
+ state,
82
+ });
83
+ settled = true;
84
+ res.writeHead(200, { 'Content-Type': 'text/html' });
85
+ res.end(successHtml());
86
+ if (timeoutId)
87
+ clearTimeout(timeoutId);
88
+ server.close(() => {
89
+ /* noop */
90
+ });
91
+ resolveCallback(tokenSet);
92
+ }
93
+ catch (err) {
94
+ if (settled)
95
+ return;
96
+ settled = true;
97
+ const message = err instanceof Error ? err.message : 'Unknown error during authentication';
98
+ res.writeHead(200, { 'Content-Type': 'text/html' });
99
+ res.end(errorHtml(message));
100
+ if (timeoutId)
101
+ clearTimeout(timeoutId);
102
+ server.close(() => {
103
+ /* noop */
104
+ });
105
+ if (err instanceof openid_client_1.errors.OPError) {
106
+ rejectCallback(new Error(err.error_description ?? err.message));
107
+ }
108
+ else if (err instanceof Error) {
109
+ rejectCallback(err);
110
+ }
111
+ else {
112
+ rejectCallback(new Error(message));
113
+ }
114
+ }
115
+ });
116
+ return new Promise((resolveSetup, rejectSetup) => {
117
+ server.listen(port, 'localhost', () => {
118
+ resolveSetup({
119
+ authUrl,
120
+ cancel: () => {
121
+ if (!settled) {
122
+ settled = true;
123
+ if (timeoutId)
124
+ clearTimeout(timeoutId);
125
+ rejectCallback(new LoginCancelledError());
126
+ }
127
+ server.close(() => {
128
+ /* noop */
129
+ });
130
+ },
131
+ waitForCallback: () => {
132
+ if (!settled) {
133
+ timeoutId = setTimeout(() => {
134
+ if (!settled) {
135
+ settled = true;
136
+ server.close(() => {
137
+ /* noop */
138
+ });
139
+ rejectCallback(new Error('Login timed out after 5 minutes. Please try again.'));
140
+ }
141
+ }, LOGIN_TIMEOUT_MS);
142
+ }
143
+ return callbackPromise;
144
+ },
145
+ });
146
+ });
147
+ server.on('error', (err) => {
148
+ rejectSetup(new Error(`Failed to start authentication server on port ${port}: ${err.message}`));
149
+ });
150
+ });
151
+ }
152
+ exports.startPkceFlow = startPkceFlow;
@@ -0,0 +1,148 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.isTokenValid = exports.removeToken = exports.getToken = exports.saveToken = void 0;
7
+ const node_fs_1 = require("node:fs");
8
+ const node_os_1 = require("node:os");
9
+ const node_path_1 = require("node:path");
10
+ const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
11
+ const CONFIG_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), '.config', 'linkapp');
12
+ const TOKEN_FILE = (0, node_path_1.join)(CONFIG_DIR, 'auth-token.json');
13
+ const TOKEN_EXPIRY_THRESHOLD_SECONDS = 60; // Consider tokens as expired within the last 60s of actual expiry
14
+ function normalizeClaimValues(claim) {
15
+ if (!claim) {
16
+ return [];
17
+ }
18
+ const values = Array.isArray(claim) ? claim : [claim];
19
+ return values
20
+ .flatMap((value) => value.split(/[,\s]+/))
21
+ .map((value) => value.trim())
22
+ .filter((value) => value.length > 0);
23
+ }
24
+ /**
25
+ * Validates that a JWT token has the correct structure and permissions
26
+ * @throws Error if token is invalid or lacks permissions
27
+ */
28
+ function validateTokenStructure(tokenString) {
29
+ const payload = jsonwebtoken_1.default.decode(tokenString, { json: true });
30
+ if (!payload) {
31
+ throw new Error('There was an error parsing the access token, please try logging in again');
32
+ }
33
+ const permissions = normalizeClaimValues(payload.permissions);
34
+ if (permissions.length === 0) {
35
+ // Descope and other providers may encode permissions in scope-like claims.
36
+ const scopePermissions = [...normalizeClaimValues(payload.scope), ...normalizeClaimValues(payload.scp)].filter((s) => /[:./]/.test(s));
37
+ if (scopePermissions.length === 0) {
38
+ throw new Error('Login request was successful, but the authenticated user does not have appropriate permissions to view or modify LinkApps');
39
+ }
40
+ }
41
+ }
42
+ /**
43
+ * Type guard to check if stored data is in legacy format
44
+ */
45
+ function isLegacyFormat(data) {
46
+ return typeof data === 'object' && data !== null && 'accessToken' in data && 'audience' in data && !('tokens' in data);
47
+ }
48
+ /**
49
+ * Reads the token storage, migrating from legacy format if needed
50
+ */
51
+ function readTokenStorage() {
52
+ if (!(0, node_fs_1.existsSync)(TOKEN_FILE)) {
53
+ return { tokens: {} };
54
+ }
55
+ try {
56
+ const rawData = JSON.parse((0, node_fs_1.readFileSync)(TOKEN_FILE, 'utf-8'));
57
+ // Check if it's legacy format and migrate
58
+ if (isLegacyFormat(rawData)) {
59
+ const migrated = {
60
+ tokens: {
61
+ [rawData.audience]: {
62
+ accessToken: rawData.accessToken,
63
+ expiresAt: rawData.expiresAt,
64
+ },
65
+ },
66
+ };
67
+ // Write migrated format back to disk
68
+ (0, node_fs_1.writeFileSync)(TOKEN_FILE, JSON.stringify(migrated, null, 2), 'utf-8');
69
+ return migrated;
70
+ }
71
+ // Already in new format
72
+ return rawData;
73
+ }
74
+ catch (error) {
75
+ console.error('Failed to read token storage:', error);
76
+ return { tokens: {} };
77
+ }
78
+ }
79
+ /**
80
+ * Writes the token storage to disk
81
+ */
82
+ function writeTokenStorage(storage) {
83
+ // Ensure config directory exists
84
+ if (!(0, node_fs_1.existsSync)(CONFIG_DIR)) {
85
+ (0, node_fs_1.mkdirSync)(CONFIG_DIR, { recursive: true });
86
+ }
87
+ (0, node_fs_1.writeFileSync)(TOKEN_FILE, JSON.stringify(storage, null, 2), 'utf-8');
88
+ }
89
+ function saveToken(token, audience, expiresAt) {
90
+ // Validate token structure and permissions before saving
91
+ validateTokenStructure(token);
92
+ // Read existing storage (handles migration if needed)
93
+ const storage = readTokenStorage();
94
+ // Add or update token for this audience
95
+ storage.tokens[audience] = {
96
+ accessToken: token,
97
+ expiresAt,
98
+ };
99
+ // Write updated storage
100
+ writeTokenStorage(storage);
101
+ }
102
+ exports.saveToken = saveToken;
103
+ function getToken(audience) {
104
+ // Read storage (handles migration if needed)
105
+ const storage = readTokenStorage();
106
+ // Get token for this audience
107
+ const tokenEntry = storage.tokens[audience];
108
+ if (!tokenEntry) {
109
+ return null;
110
+ }
111
+ // Check if token is expired (with threshold buffer)
112
+ if (tokenEntry.expiresAt && tokenEntry.expiresAt - TOKEN_EXPIRY_THRESHOLD_SECONDS < Math.floor(Date.now() / 1000)) {
113
+ // Remove expired token
114
+ removeToken(audience);
115
+ return null;
116
+ }
117
+ return tokenEntry.accessToken;
118
+ }
119
+ exports.getToken = getToken;
120
+ function removeToken(audience) {
121
+ // Read storage (handles migration if needed)
122
+ const storage = readTokenStorage();
123
+ // If audience is specified, only remove that token
124
+ if (audience) {
125
+ if (storage.tokens[audience]) {
126
+ delete storage.tokens[audience];
127
+ // If there are remaining tokens, write updated storage
128
+ // Otherwise, delete the file
129
+ if (Object.keys(storage.tokens).length > 0) {
130
+ writeTokenStorage(storage);
131
+ }
132
+ else if ((0, node_fs_1.existsSync)(TOKEN_FILE)) {
133
+ (0, node_fs_1.unlinkSync)(TOKEN_FILE);
134
+ }
135
+ }
136
+ }
137
+ else {
138
+ // No audience specified - remove all tokens
139
+ if ((0, node_fs_1.existsSync)(TOKEN_FILE)) {
140
+ (0, node_fs_1.unlinkSync)(TOKEN_FILE);
141
+ }
142
+ }
143
+ }
144
+ exports.removeToken = removeToken;
145
+ function isTokenValid(audience) {
146
+ return getToken(audience) !== null;
147
+ }
148
+ exports.isTokenValid = isTokenValid;
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.2.4",
2
+ "version": "2.2.5",
3
3
  "commands": {
4
4
  "build": {
5
5
  "id": "build",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/create-link-app",
3
- "version": "2.2.4",
3
+ "version": "2.2.5",
4
4
  "description": "Create a Link App on Linktr.ee.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Linktree",
@@ -55,6 +55,7 @@
55
55
  "babel-loader": "^8.2.5",
56
56
  "camelcase": "^6.3.0",
57
57
  "css-loader": "^6.7.3",
58
+ "detect-port": "^2.1.0",
58
59
  "fork-ts-checker-webpack-plugin": "^7.2.11",
59
60
  "form-data": "^4.0.0",
60
61
  "fs-extra": "^10.1.0",
@@ -62,7 +63,6 @@
62
63
  "inject-body-webpack-plugin": "^1.3.0",
63
64
  "jsonwebtoken": "^8.5.1",
64
65
  "mini-css-extract-plugin": "^2.7.5",
65
- "netrc-parser": "^3.1.6",
66
66
  "open": "^8.4.0",
67
67
  "openid-client": "^5.1.6",
68
68
  "picocolors": "^1.0.0",
@@ -1,83 +0,0 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.getAccessToken = exports.removeAccessToken = exports.saveAccessToken = exports.isTokenValid = void 0;
7
- const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
8
- const netrc_parser_1 = __importDefault(require("netrc-parser"));
9
- const TokenExpiryThresholdSeconds = 60; // Used to consider tokens as 'expired' within the last x seconds of its actual expiry to ensure slow requests still use a valid token
10
- function normalizeClaimValues(claim) {
11
- if (!claim) {
12
- return [];
13
- }
14
- const values = Array.isArray(claim) ? claim : [claim];
15
- return values
16
- .flatMap((value) => value.split(/[,\s]+/))
17
- .map((value) => value.trim())
18
- .filter((value) => value.length > 0);
19
- }
20
- function isTokenValid(token) {
21
- if (!token.access_token || token.token_type !== 'Bearer') {
22
- throw new Error('Received access token is invalid');
23
- }
24
- const payload = jsonwebtoken_1.default.decode(token.access_token, { json: true });
25
- if (!payload) {
26
- throw new Error('There was an error parsing the access token, please try logging in again');
27
- }
28
- const permissions = normalizeClaimValues(payload.permissions);
29
- if (permissions.length === 0) {
30
- // Descope and other providers may encode permissions in scope-like claims.
31
- const scopePermissions = [...normalizeClaimValues(payload.scope), ...normalizeClaimValues(payload.scp)].filter((v) => /[:.]/u.test(v));
32
- if (scopePermissions.length === 0) {
33
- throw new Error('Login request was successful, but the authenticated user does not have appropriate permissions to view or modify Link Apps');
34
- }
35
- }
36
- return true;
37
- }
38
- exports.isTokenValid = isTokenValid;
39
- function saveAccessToken(tokenString, audience) {
40
- if (!audience) {
41
- // Fallback: derive from JWT aud claim (backward compat for external callers)
42
- const token = jsonwebtoken_1.default.decode(tokenString, { json: true });
43
- const rawAudience = token?.aud;
44
- audience = Array.isArray(rawAudience) ? rawAudience[0] : rawAudience;
45
- }
46
- if (audience) {
47
- netrc_parser_1.default.loadSync();
48
- netrc_parser_1.default.machines[audience] = {
49
- login: 'authToken',
50
- password: tokenString,
51
- };
52
- netrc_parser_1.default.saveSync();
53
- }
54
- else {
55
- throw new Error('Error whilst trying to save access token, please try logging in again');
56
- }
57
- }
58
- exports.saveAccessToken = saveAccessToken;
59
- function removeAccessToken(audience) {
60
- netrc_parser_1.default.loadSync();
61
- netrc_parser_1.default.machines[audience] = {
62
- login: undefined,
63
- password: undefined,
64
- };
65
- netrc_parser_1.default.saveSync();
66
- }
67
- exports.removeAccessToken = removeAccessToken;
68
- function getAccessToken(audience) {
69
- netrc_parser_1.default.loadSync();
70
- const tokenString = netrc_parser_1.default.machines[audience]?.login === 'authToken' ? netrc_parser_1.default.machines[audience].password : undefined;
71
- if (!tokenString) {
72
- throw new Error('Not logged in. Please login using command: login');
73
- }
74
- const token = jsonwebtoken_1.default.decode(tokenString, { json: true });
75
- if (!token?.exp) {
76
- throw new Error('Cached login token is invalid. Please login again using command: login');
77
- }
78
- if (token.exp - TokenExpiryThresholdSeconds < Math.floor(Date.now() / 1000)) {
79
- throw new Error('Login expired. Please login again using command: login');
80
- }
81
- return tokenString;
82
- }
83
- exports.getAccessToken = getAccessToken;
@@ -1,34 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.pollAccessToken = exports.initDeviceAuthorization = void 0;
4
- const openid_client_1 = require("openid-client");
5
- async function initDeviceAuthorization(config) {
6
- const { domain, client_id, audience, project_id } = config;
7
- if (!project_id) {
8
- throw new Error('Authentication configuration is missing the project_id. Please update to the latest CLI version and try again.');
9
- }
10
- const issuer = new openid_client_1.Issuer({
11
- issuer: `${domain}/v1/apps/customized/${project_id}`,
12
- device_authorization_endpoint: `${domain}/oauth2/v1/apps/${project_id}/device`,
13
- token_endpoint: `${domain}/oauth2/v1/apps/${project_id}/token`,
14
- jwks_uri: `${domain}/${project_id}/.well-known/jwks.json`,
15
- });
16
- const client = new issuer.Client({
17
- client_id,
18
- token_endpoint_auth_method: 'none',
19
- });
20
- return client.deviceAuthorization({ scope: 'openid', audience });
21
- }
22
- exports.initDeviceAuthorization = initDeviceAuthorization;
23
- async function pollAccessToken(handle) {
24
- try {
25
- return await handle.poll();
26
- }
27
- catch (err) {
28
- if (err instanceof openid_client_1.errors.OPError) {
29
- throw new Error(err.error_description);
30
- }
31
- throw err;
32
- }
33
- }
34
- exports.pollAccessToken = pollAccessToken;