@deploily/deploily-cli 0.1.0
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.
- package/.devcontainer/devcontainer.json +25 -0
- package/.devcontainer/docker-compose.yml +18 -0
- package/.env.example +8 -0
- package/.github/dependabot.yml +12 -0
- package/.github/workflows/publish.yml +39 -0
- package/LICENSE +201 -0
- package/README.md +105 -0
- package/dist/auth/callback.d.ts +3 -0
- package/dist/auth/callback.d.ts.map +1 -0
- package/dist/auth/callback.js +143 -0
- package/dist/auth/callback.js.map +1 -0
- package/dist/auth/index.d.ts +23 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +173 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/commands/auth.d.ts +4 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +90 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/config/constants.d.ts +22 -0
- package/dist/config/constants.d.ts.map +1 -0
- package/dist/config/constants.js +25 -0
- package/dist/config/constants.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +61 -0
- package/dist/index.js.map +1 -0
- package/dist/storage/credentials.d.ts +47 -0
- package/dist/storage/credentials.d.ts.map +1 -0
- package/dist/storage/credentials.js +160 -0
- package/dist/storage/credentials.js.map +1 -0
- package/dist/utils/pkce.d.ts +5 -0
- package/dist/utils/pkce.d.ts.map +1 -0
- package/dist/utils/pkce.js +30 -0
- package/dist/utils/pkce.js.map +1 -0
- package/package.json +37 -0
- package/pnpm-workspace.yaml +5 -0
- package/src/auth/callback.ts +159 -0
- package/src/auth/index.ts +238 -0
- package/src/commands/auth.ts +114 -0
- package/src/config/constants.ts +28 -0
- package/src/index.ts +69 -0
- package/src/storage/credentials.ts +188 -0
- package/src/types/index.d.ts +35 -0
- package/src/utils/pkce.ts +36 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
// Generate a cryptographically secure random string
|
|
3
|
+
export function generateRandomString(length = 32) {
|
|
4
|
+
return crypto.randomBytes(length).toString("hex");
|
|
5
|
+
}
|
|
6
|
+
// Generate PKCE code verifier
|
|
7
|
+
export function generateCodeVerifier() {
|
|
8
|
+
const length = 128;
|
|
9
|
+
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
|
|
10
|
+
let result = "";
|
|
11
|
+
const randomValues = crypto.getRandomValues(new Uint8Array(length));
|
|
12
|
+
for (let i = 0; i < length; i++) {
|
|
13
|
+
result += charset[randomValues[i] % charset.length];
|
|
14
|
+
}
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
// Generate PKCE code challenge from verifier
|
|
18
|
+
export function generateCodeChallenge(codeVerifier) {
|
|
19
|
+
const hash = crypto.createHash("sha256").update(codeVerifier).digest();
|
|
20
|
+
return hash
|
|
21
|
+
.toString("base64")
|
|
22
|
+
.replace(/\+/g, "-")
|
|
23
|
+
.replace(/\//g, "_")
|
|
24
|
+
.replace(/=+$/, "");
|
|
25
|
+
}
|
|
26
|
+
// Generate state parameter for CSRF protection
|
|
27
|
+
export function generateState() {
|
|
28
|
+
return generateRandomString(32);
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=pkce.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pkce.js","sourceRoot":"","sources":["../../src/utils/pkce.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,QAAQ,CAAA;AAE3B,oDAAoD;AACpD,MAAM,UAAU,oBAAoB,CAAC,SAAiB,EAAE;IACtD,OAAO,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAA;AACnD,CAAC;AAED,8BAA8B;AAC9B,MAAM,UAAU,oBAAoB;IAClC,MAAM,MAAM,GAAG,GAAG,CAAA;IAClB,MAAM,OAAO,GACX,oEAAoE,CAAA;IACtE,IAAI,MAAM,GAAG,EAAE,CAAA;IACf,MAAM,YAAY,GAAG,MAAM,CAAC,eAAe,CAAC,IAAI,UAAU,CAAC,MAAM,CAAC,CAAC,CAAA;IAEnE,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QAChC,MAAM,IAAI,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IACrD,CAAC;IAED,OAAO,MAAM,CAAA;AACf,CAAC;AAED,6CAA6C;AAC7C,MAAM,UAAU,qBAAqB,CAAC,YAAoB;IACxD,MAAM,IAAI,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC,MAAM,EAAE,CAAA;IACtE,OAAO,IAAI;SACR,QAAQ,CAAC,QAAQ,CAAC;SAClB,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC;SACnB,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC;SACnB,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAA;AACvB,CAAC;AAED,+CAA+C;AAC/C,MAAM,UAAU,aAAa;IAC3B,OAAO,oBAAoB,CAAC,EAAE,CAAC,CAAA;AACjC,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@deploily/deploily-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Deploily CLI - Deploy and manage your applications with ease",
|
|
5
|
+
"license": "APACHE-2.0",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"cli",
|
|
8
|
+
"deployment",
|
|
9
|
+
"deploily"
|
|
10
|
+
],
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "dist/index.js",
|
|
13
|
+
"bin": {
|
|
14
|
+
"deploily": "dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc",
|
|
18
|
+
"dev": "tsx src/index.ts",
|
|
19
|
+
"start": "node dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@deploily/deploily-cli": "link:",
|
|
23
|
+
"@hono/node-server": "^2.0.6",
|
|
24
|
+
"axios": "^1.18.1",
|
|
25
|
+
"chalk": "^5.6.2",
|
|
26
|
+
"dotenv": "^17.4.2",
|
|
27
|
+
"open": "^11.0.0"
|
|
28
|
+
},
|
|
29
|
+
"optionalDependencies": {
|
|
30
|
+
"keytar": "^7.9.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^25.9.3",
|
|
34
|
+
"tsx": "^4.22.4",
|
|
35
|
+
"typescript": "^6.0.3"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import http from "http"
|
|
2
|
+
import { URL } from "url"
|
|
3
|
+
import { AUTH_CONFIG } from "../config/constants.js"
|
|
4
|
+
import { CallbackResult } from "../types/index.js"
|
|
5
|
+
|
|
6
|
+
// Start local HTTP server to listen for OAuth callback
|
|
7
|
+
export async function startCallbackServer(
|
|
8
|
+
expectedState: string,
|
|
9
|
+
): Promise<CallbackResult> {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const server = http.createServer((req, res) => {
|
|
12
|
+
if (!req.url) {
|
|
13
|
+
res.writeHead(400)
|
|
14
|
+
res.end("Bad request")
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const url = new URL(
|
|
19
|
+
req.url,
|
|
20
|
+
`http://localhost:${AUTH_CONFIG.CALLBACK_PORT}`,
|
|
21
|
+
)
|
|
22
|
+
const code = url.searchParams.get("code")
|
|
23
|
+
const state = url.searchParams.get("state")
|
|
24
|
+
const error = url.searchParams.get("error")
|
|
25
|
+
const errorDescription = url.searchParams.get("error_description")
|
|
26
|
+
|
|
27
|
+
// Handle errors from Keycloak
|
|
28
|
+
if (error) {
|
|
29
|
+
const errorMsg = errorDescription || error
|
|
30
|
+
res.writeHead(400, { "Content-Type": "text/html" })
|
|
31
|
+
res.end(`
|
|
32
|
+
<!DOCTYPE html>
|
|
33
|
+
<html>
|
|
34
|
+
<head>
|
|
35
|
+
<title>Authentication Failed</title>
|
|
36
|
+
<style>
|
|
37
|
+
body {
|
|
38
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
39
|
+
display: flex;
|
|
40
|
+
justify-content: center;
|
|
41
|
+
align-items: center;
|
|
42
|
+
height: 100vh;
|
|
43
|
+
margin: 0;
|
|
44
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
45
|
+
}
|
|
46
|
+
.container {
|
|
47
|
+
background: white;
|
|
48
|
+
padding: 40px;
|
|
49
|
+
border-radius: 8px;
|
|
50
|
+
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
|
51
|
+
max-width: 500px;
|
|
52
|
+
text-align: center;
|
|
53
|
+
}
|
|
54
|
+
h1 { color: #e74c3c; margin-top: 0; }
|
|
55
|
+
p { color: #555; line-height: 1.6; }
|
|
56
|
+
code { background: #f5f5f5; padding: 2px 6px; border-radius: 3px; }
|
|
57
|
+
</style>
|
|
58
|
+
</head>
|
|
59
|
+
<body>
|
|
60
|
+
<div class="container">
|
|
61
|
+
<h1>Authentication Failed</h1>
|
|
62
|
+
<p>Error: <code>${escapeHtml(errorMsg)}</code></p>
|
|
63
|
+
<p>Please try again with: <code>deploily login</code></p>
|
|
64
|
+
</div>
|
|
65
|
+
</body>
|
|
66
|
+
</html>
|
|
67
|
+
`)
|
|
68
|
+
server.close()
|
|
69
|
+
reject(new Error(`Authentication failed: ${errorMsg}`))
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Validate callback
|
|
74
|
+
if (!code || !state) {
|
|
75
|
+
res.writeHead(400)
|
|
76
|
+
res.end("Missing code or state parameter")
|
|
77
|
+
server.close()
|
|
78
|
+
reject(new Error("Missing code or state parameter"))
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Validate state parameter (CSRF protection)
|
|
83
|
+
if (state !== expectedState) {
|
|
84
|
+
res.writeHead(403)
|
|
85
|
+
res.end("Invalid state parameter")
|
|
86
|
+
server.close()
|
|
87
|
+
reject(new Error("State parameter mismatch"))
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Success response
|
|
92
|
+
res.writeHead(200, { "Content-Type": "text/html" })
|
|
93
|
+
res.end(`
|
|
94
|
+
<!DOCTYPE html>
|
|
95
|
+
<html>
|
|
96
|
+
<head>
|
|
97
|
+
<title>Authentication Successful</title>
|
|
98
|
+
<style>
|
|
99
|
+
body {
|
|
100
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
101
|
+
display: flex;
|
|
102
|
+
justify-content: center;
|
|
103
|
+
align-items: center;
|
|
104
|
+
height: 100vh;
|
|
105
|
+
margin: 0;
|
|
106
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
107
|
+
}
|
|
108
|
+
.container {
|
|
109
|
+
background: white;
|
|
110
|
+
padding: 40px;
|
|
111
|
+
border-radius: 8px;
|
|
112
|
+
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
|
113
|
+
max-width: 500px;
|
|
114
|
+
text-align: center;
|
|
115
|
+
}
|
|
116
|
+
h1 { color: #27ae60; margin-top: 0; }
|
|
117
|
+
p { color: #555; line-height: 1.6; }
|
|
118
|
+
</style>
|
|
119
|
+
</head>
|
|
120
|
+
<body>
|
|
121
|
+
<div class="container">
|
|
122
|
+
<h1>Authentication Successful</h1>
|
|
123
|
+
<p>You have successfully authenticated with Deploily.</p>
|
|
124
|
+
<p>You can close this window and return to your terminal.</p>
|
|
125
|
+
</div>
|
|
126
|
+
</body>
|
|
127
|
+
</html>
|
|
128
|
+
`)
|
|
129
|
+
|
|
130
|
+
server.close()
|
|
131
|
+
resolve({ code, state })
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
server.listen(AUTH_CONFIG.CALLBACK_PORT, "localhost", () => {
|
|
135
|
+
// Server is listening
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
// Set a timeout for the callback
|
|
139
|
+
const timeout = setTimeout(() => {
|
|
140
|
+
server.close()
|
|
141
|
+
reject(new Error("Callback timeout: No response from Keycloak"))
|
|
142
|
+
}, 300000) // 5 minutes
|
|
143
|
+
|
|
144
|
+
server.on("close", () => {
|
|
145
|
+
clearTimeout(timeout)
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function escapeHtml(text: string): string {
|
|
151
|
+
const map: Record<string, string> = {
|
|
152
|
+
"&": "&",
|
|
153
|
+
"<": "<",
|
|
154
|
+
">": ">",
|
|
155
|
+
'"': """,
|
|
156
|
+
"'": "'",
|
|
157
|
+
}
|
|
158
|
+
return text.replace(/[&<>"']/g, (m) => map[m])
|
|
159
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import axios, { AxiosInstance } from "axios"
|
|
2
|
+
import {
|
|
3
|
+
generateCodeVerifier,
|
|
4
|
+
generateCodeChallenge,
|
|
5
|
+
generateState,
|
|
6
|
+
} from "../utils/pkce.js"
|
|
7
|
+
import { AUTH_CONFIG } from "../config/constants.js"
|
|
8
|
+
import { credentialStorage } from "../storage/credentials.js"
|
|
9
|
+
import {
|
|
10
|
+
AuthContext,
|
|
11
|
+
StoredCredentials,
|
|
12
|
+
TokenResponse,
|
|
13
|
+
UserInfo,
|
|
14
|
+
} from "../types/index.js"
|
|
15
|
+
|
|
16
|
+
class AuthenticationService {
|
|
17
|
+
private client: AxiosInstance
|
|
18
|
+
|
|
19
|
+
constructor() {
|
|
20
|
+
this.client = axios.create({
|
|
21
|
+
timeout: 10000,
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private buildClientAuthHeaders(): Record<string, string> {
|
|
26
|
+
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET
|
|
27
|
+
|
|
28
|
+
if (!clientSecret) {
|
|
29
|
+
return {}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const clientId = process.env.KEYCLOAK_CLIENT_ID!
|
|
33
|
+
const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString(
|
|
34
|
+
"base64",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
Authorization: `Basic ${basicAuth}`,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Generate authorization URL for user login
|
|
43
|
+
generateAuthorizationUrl(): { url: string; context: AuthContext } {
|
|
44
|
+
const state = generateState()
|
|
45
|
+
const codeVerifier = generateCodeVerifier()
|
|
46
|
+
const codeChallenge = generateCodeChallenge(codeVerifier)
|
|
47
|
+
|
|
48
|
+
const params = new URLSearchParams({
|
|
49
|
+
client_id: AUTH_CONFIG.CLIENT_ID,
|
|
50
|
+
response_type: AUTH_CONFIG.RESPONSE_TYPE,
|
|
51
|
+
redirect_uri: AUTH_CONFIG.REDIRECT_URI,
|
|
52
|
+
scope: AUTH_CONFIG.SCOPE,
|
|
53
|
+
code_challenge: codeChallenge,
|
|
54
|
+
code_challenge_method: AUTH_CONFIG.CODE_CHALLENGE_METHOD,
|
|
55
|
+
state,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const url = `${AUTH_CONFIG.KEYCLOAK_AUTH_ENDPOINT}?${params.toString()}`
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
url,
|
|
62
|
+
context: { state, codeVerifier, codeChallenge },
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Exchange authorization code for tokens
|
|
67
|
+
async exchangeCodeForToken(
|
|
68
|
+
code: string,
|
|
69
|
+
codeVerifier: string,
|
|
70
|
+
): Promise<TokenResponse> {
|
|
71
|
+
try {
|
|
72
|
+
const body = new URLSearchParams({
|
|
73
|
+
grant_type: "authorization_code",
|
|
74
|
+
code,
|
|
75
|
+
client_id: process.env.KEYCLOAK_CLIENT_ID!,
|
|
76
|
+
redirect_uri: process.env.KEYCLOAK_REDIRECT_URI!,
|
|
77
|
+
code_verifier: codeVerifier,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const response = await this.client.post<TokenResponse>(
|
|
81
|
+
process.env.KEYCLOAK_TOKEN_ENDPOINT!,
|
|
82
|
+
body,
|
|
83
|
+
{
|
|
84
|
+
headers: {
|
|
85
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
86
|
+
...this.buildClientAuthHeaders(),
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return response.data
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (axios.isAxiosError(error)) {
|
|
94
|
+
const responseData =
|
|
95
|
+
typeof error.response?.data === "string"
|
|
96
|
+
? error.response.data
|
|
97
|
+
: JSON.stringify(error.response?.data ?? {})
|
|
98
|
+
const authHint =
|
|
99
|
+
error.response?.status === 401 &&
|
|
100
|
+
responseData.includes("unauthorized_client")
|
|
101
|
+
? " This usually means the Keycloak client is confidential and KEYCLOAK_CLIENT_SECRET is missing or invalid."
|
|
102
|
+
: ""
|
|
103
|
+
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Failed to exchange code for token: ${error.message}${responseData ? ` (${responseData})` : ""}${authHint}`,
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Failed to exchange code for token: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Refresh access token using refresh token
|
|
116
|
+
async refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
|
|
117
|
+
try {
|
|
118
|
+
const body = new URLSearchParams({
|
|
119
|
+
grant_type: "refresh_token",
|
|
120
|
+
refresh_token: refreshToken,
|
|
121
|
+
client_id: process.env.KEYCLOAK_CLIENT_ID!,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const response = await this.client.post<TokenResponse>(
|
|
125
|
+
process.env.KEYCLOAK_TOKEN_ENDPOINT!,
|
|
126
|
+
body,
|
|
127
|
+
{
|
|
128
|
+
headers: {
|
|
129
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
130
|
+
...this.buildClientAuthHeaders(),
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return response.data
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Failed to refresh token: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Get user information from access token
|
|
144
|
+
async getUserInfo(accessToken: string): Promise<UserInfo> {
|
|
145
|
+
try {
|
|
146
|
+
const response = await this.client.get<UserInfo>(
|
|
147
|
+
process.env.KEYCLOAK_USERINFO_ENDPOINT!,
|
|
148
|
+
{
|
|
149
|
+
headers: {
|
|
150
|
+
Authorization: `Bearer ${accessToken}`,
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return response.data
|
|
156
|
+
} catch (error) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`Failed to fetch user info: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Store tokens after successful authentication
|
|
164
|
+
async storeTokens(token: TokenResponse, userEmail?: string): Promise<void> {
|
|
165
|
+
const expiresAt = Math.floor(Date.now() / 1000) + token.expires_in
|
|
166
|
+
|
|
167
|
+
const credentials: StoredCredentials = {
|
|
168
|
+
access_token: token.access_token,
|
|
169
|
+
refresh_token: token.refresh_token,
|
|
170
|
+
expires_at: expiresAt,
|
|
171
|
+
user_email: userEmail,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
await credentialStorage.save(credentials)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Get valid access token
|
|
178
|
+
async getValidAccessToken(): Promise<string | null> {
|
|
179
|
+
const credentials = await credentialStorage.load()
|
|
180
|
+
if (!credentials) {
|
|
181
|
+
return null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Check if token is expired
|
|
185
|
+
if (credentials.expires_at < Date.now() / 1000) {
|
|
186
|
+
try {
|
|
187
|
+
const newToken = await this.refreshAccessToken(
|
|
188
|
+
credentials.refresh_token,
|
|
189
|
+
)
|
|
190
|
+
await this.storeTokens(newToken, credentials.user_email)
|
|
191
|
+
return newToken.access_token
|
|
192
|
+
} catch (error) {
|
|
193
|
+
// Token refresh failed, need to re-authenticate
|
|
194
|
+
await credentialStorage.clear()
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return credentials.access_token
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Logout and clear stored tokens
|
|
203
|
+
async logout(): Promise<void> {
|
|
204
|
+
const credentials = await credentialStorage.load()
|
|
205
|
+
|
|
206
|
+
if (credentials) {
|
|
207
|
+
try {
|
|
208
|
+
const body = new URLSearchParams({
|
|
209
|
+
client_id: process.env.KEYCLOAK_CLIENT_ID!,
|
|
210
|
+
refresh_token: credentials.refresh_token,
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// Optionally call Keycloak logout endpoint
|
|
214
|
+
await this.client.post(process.env.KEYCLOAK_LOGOUT_ENDPOINT!, body, {
|
|
215
|
+
headers: {
|
|
216
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
217
|
+
...this.buildClientAuthHeaders(),
|
|
218
|
+
},
|
|
219
|
+
})
|
|
220
|
+
} catch (error) {
|
|
221
|
+
// Continue with local logout even if server call fails
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Clear local credentials
|
|
226
|
+
await credentialStorage.clear()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Check if user is authenticated
|
|
231
|
+
*/
|
|
232
|
+
async isAuthenticated(): Promise<boolean> {
|
|
233
|
+
const credentials = await credentialStorage.load()
|
|
234
|
+
return credentials !== null
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export const authService = new AuthenticationService()
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import open from "open"
|
|
2
|
+
import { authService } from "../auth/index.js"
|
|
3
|
+
import { startCallbackServer } from "../auth/callback.js"
|
|
4
|
+
import { credentialStorage } from "../storage/credentials.js"
|
|
5
|
+
import chalk from "chalk"
|
|
6
|
+
|
|
7
|
+
export async function handleLogin() {
|
|
8
|
+
try {
|
|
9
|
+
// 1. Check if logged in
|
|
10
|
+
const isAuth = await authService.isAuthenticated()
|
|
11
|
+
if (isAuth) {
|
|
12
|
+
console.log("Already logged in. Use 'deploily logout' to log out first.")
|
|
13
|
+
return
|
|
14
|
+
}
|
|
15
|
+
console.log("Opening browser for authentication...")
|
|
16
|
+
|
|
17
|
+
// 2. Generate authorization URL
|
|
18
|
+
const { url, context } = authService.generateAuthorizationUrl()
|
|
19
|
+
|
|
20
|
+
// 3. Start callback server
|
|
21
|
+
const callbackPromise = startCallbackServer(context.state)
|
|
22
|
+
|
|
23
|
+
// 4. Open browser
|
|
24
|
+
await open(url)
|
|
25
|
+
|
|
26
|
+
console.log("Waiting for authentication...")
|
|
27
|
+
|
|
28
|
+
// 5. Wait for callback
|
|
29
|
+
const callback = await callbackPromise
|
|
30
|
+
|
|
31
|
+
// 6. Exchange code for token
|
|
32
|
+
const tokens = await authService.exchangeCodeForToken(
|
|
33
|
+
callback.code,
|
|
34
|
+
context.codeVerifier,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
// 7. Get user info
|
|
38
|
+
const userInfo = await authService.getUserInfo(tokens.access_token)
|
|
39
|
+
|
|
40
|
+
// 8. Store tokens
|
|
41
|
+
await authService.storeTokens(tokens, userInfo.email)
|
|
42
|
+
console.log("Successfully authenticated")
|
|
43
|
+
console.log(`Welcome, ${userInfo.email}!`)
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.error("Error occurred while logging in:", error)
|
|
46
|
+
process.exit(1)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function handleLogout() {
|
|
51
|
+
try {
|
|
52
|
+
const isAuth = await authService.isAuthenticated()
|
|
53
|
+
|
|
54
|
+
if (!isAuth) {
|
|
55
|
+
console.log("You are not logged in.")
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await authService.logout()
|
|
60
|
+
console.log("Successfully logged out")
|
|
61
|
+
} catch (error) {
|
|
62
|
+
console.error("Error occurred while logging in:", error)
|
|
63
|
+
process.exit(1)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function handleWhoami() {
|
|
68
|
+
try {
|
|
69
|
+
const isAuth = await authService.isAuthenticated()
|
|
70
|
+
|
|
71
|
+
if (!isAuth) {
|
|
72
|
+
console.log(
|
|
73
|
+
"You are not logged in. Use `deploily login` to authenticate.",
|
|
74
|
+
)
|
|
75
|
+
process.exit(1)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Get stored credentials
|
|
79
|
+
const credentials = await credentialStorage.load()
|
|
80
|
+
|
|
81
|
+
if (!credentials) {
|
|
82
|
+
console.log(
|
|
83
|
+
"You are not logged in. Use `deploily login` to authenticate.",
|
|
84
|
+
)
|
|
85
|
+
process.exit(1)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Get user info from API
|
|
89
|
+
const userInfo = await authService.getUserInfo(credentials.access_token)
|
|
90
|
+
|
|
91
|
+
console.log("Current User:")
|
|
92
|
+
console.log(`├─ Email: ${userInfo.email}`)
|
|
93
|
+
console.log(`├─ Name: ${userInfo.name}`)
|
|
94
|
+
console.log(`├─ Username: ${userInfo.preferred_username}`)
|
|
95
|
+
|
|
96
|
+
// Calculate token expiry
|
|
97
|
+
const expiresAt = new Date(credentials.expires_at * 1000)
|
|
98
|
+
const now = new Date()
|
|
99
|
+
const timeRemaining = Math.floor(
|
|
100
|
+
(credentials.expires_at * 1000 - now.getTime()) / 1000,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if (timeRemaining > 0) {
|
|
104
|
+
const hours = Math.floor(timeRemaining / 3600)
|
|
105
|
+
const minutes = Math.floor((timeRemaining % 3600) / 60)
|
|
106
|
+
console.log(chalk.gray(`└─ Token expires in: ${hours}h ${minutes}m`))
|
|
107
|
+
} else {
|
|
108
|
+
console.log(chalk.gray(`└─ Token expires in: Soon (will auto-refresh)`))
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error("Error occurred while logging in:", error)
|
|
112
|
+
process.exit(1)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import "dotenv/config"
|
|
2
|
+
|
|
3
|
+
export const AUTH_CONFIG = {
|
|
4
|
+
// Keycloak Configuration
|
|
5
|
+
KEYCLOAK_REALM_URL: process.env.KEYCLOAK_REALM_URL!,
|
|
6
|
+
KEYCLOAK_TOKEN_ENDPOINT: process.env.KEYCLOAK_TOKEN_ENDPOINT!,
|
|
7
|
+
KEYCLOAK_AUTH_ENDPOINT: process.env.KEYCLOAK_AUTH_ENDPOINT!,
|
|
8
|
+
KEYCLOAK_LOGOUT_ENDPOINT: process.env.KEYCLOAK_LOGOUT_ENDPOINT!,
|
|
9
|
+
KEYCLOAK_USERINFO_ENDPOINT: process.env.KEYCLOAK_USERINFO_ENDPOINT!,
|
|
10
|
+
|
|
11
|
+
// CLI Configuration
|
|
12
|
+
CLIENT_ID: process.env.KEYCLOAK_CLIENT_ID!,
|
|
13
|
+
CLIENT_SECRET: process.env.KEYCLOAK_CLIENT_SECRET!,
|
|
14
|
+
REDIRECT_URI: process.env.KEYCLOAK_REDIRECT_URI!,
|
|
15
|
+
CALLBACK_PORT: 8976,
|
|
16
|
+
SCOPE: "openid profile email",
|
|
17
|
+
RESPONSE_TYPE: "code",
|
|
18
|
+
CODE_CHALLENGE_METHOD: "S256",
|
|
19
|
+
|
|
20
|
+
// Token Configuration
|
|
21
|
+
TOKEN_EXPIRY_BUFFER: 60,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const STORAGE_CONFIG = {
|
|
25
|
+
SERVICE_NAME: "deploily-cli",
|
|
26
|
+
CONFIG_DIR: "~/.config/deploily",
|
|
27
|
+
CONFIG_FILE: "config.json",
|
|
28
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { handleLogin, handleLogout, handleWhoami } from "./commands/auth.js"
|
|
3
|
+
|
|
4
|
+
const VERSION = "1.0.0" // TODO: read from package.json
|
|
5
|
+
|
|
6
|
+
async function main() {
|
|
7
|
+
const args = process.argv.slice(2)
|
|
8
|
+
const command = args[0]
|
|
9
|
+
|
|
10
|
+
if (!command || command === "--help" || command === "-h") {
|
|
11
|
+
showHelp()
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (command === "--version" || command === "-v") {
|
|
16
|
+
console.log(`deploily version ${VERSION}`)
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
switch (command) {
|
|
22
|
+
case "login":
|
|
23
|
+
await handleLogin()
|
|
24
|
+
break
|
|
25
|
+
|
|
26
|
+
case "logout":
|
|
27
|
+
await handleLogout()
|
|
28
|
+
break
|
|
29
|
+
|
|
30
|
+
case "whoami":
|
|
31
|
+
await handleWhoami()
|
|
32
|
+
break
|
|
33
|
+
|
|
34
|
+
default:
|
|
35
|
+
console.error(`Unknown command: ${command}`)
|
|
36
|
+
showHelp()
|
|
37
|
+
process.exit(1)
|
|
38
|
+
}
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error(`Error executing command '${command}':`, err)
|
|
41
|
+
process.exit(1)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function showHelp() {
|
|
46
|
+
console.log(`
|
|
47
|
+
Usage:
|
|
48
|
+
deploily [command]
|
|
49
|
+
|
|
50
|
+
Commands:
|
|
51
|
+
login Authenticate with Keycloak
|
|
52
|
+
logout Clear authentication tokens
|
|
53
|
+
whoami Show current user information
|
|
54
|
+
|
|
55
|
+
Options:
|
|
56
|
+
--help, -h Show this help message
|
|
57
|
+
--version Show version number
|
|
58
|
+
|
|
59
|
+
Examples:
|
|
60
|
+
deploily login
|
|
61
|
+
deploily logout
|
|
62
|
+
deploily whoami
|
|
63
|
+
`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
main().catch((err) => {
|
|
67
|
+
console.error(err)
|
|
68
|
+
process.exit(1)
|
|
69
|
+
})
|