captive-cli 1.2.0 → 1.3.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.
- checksums.yaml +4 -4
- data/lib/captive/cli.rb +20 -8
- data/lib/captive/commands/auth_commands.rb +16 -0
- data/lib/captive/commands/auth_token.rb +29 -0
- data/lib/captive/commands/user_login.rb +20 -13
- data/lib/captive/credentials.rb +43 -0
- data/lib/captive/credentials_store.rb +47 -0
- data/lib/captive/dashboard_api.rb +11 -9
- data/lib/captive/dashboard_client.rb +25 -25
- data/lib/captive/errors.rb +5 -0
- data/lib/captive/github_device_flow.rb +5 -3
- data/lib/captive/session_token.rb +60 -0
- data/lib/captive/token_refresher.rb +27 -0
- data/lib/captive/version.rb +1 -1
- metadata +7 -2
- data/lib/captive/token_file.rb +0 -20
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f79f7992c692e80419a6da77317d0e3d50d72ee4b48d41ea6001fa3e8a968400
|
|
4
|
+
data.tar.gz: 43220b3bb3e0b9cdaae4bf96de10d80eb3145af3f52584bff1c73fb45d4e7349
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cec86c2467edb73bb4c46c520c2fb793c1c4b67800213d5ef68348aa2ddae4c91b25e85d82fe73e599fc7cb188aee96b63feb5674b10885e7aa7100027f240ac
|
|
7
|
+
data.tar.gz: a69f88f24623b2329515daff60be1df8d3092046c51cd20b995cb6a0ba58a176dc29898640a07cdc6d9904315b10a321b7d7f6cc9cb7c301e768e991d55a3980
|
data/lib/captive/cli.rb
CHANGED
|
@@ -8,6 +8,8 @@ require_relative "commands/env_commands"
|
|
|
8
8
|
require_relative "commands/crons_commands"
|
|
9
9
|
require_relative "commands/db_commands"
|
|
10
10
|
require_relative "commands/user_login"
|
|
11
|
+
require_relative "commands/auth_commands"
|
|
12
|
+
require_relative "credentials_store"
|
|
11
13
|
require_relative "dashboard_client"
|
|
12
14
|
require_relative "github_device_flow"
|
|
13
15
|
require_relative "cluster_endpoint"
|
|
@@ -23,13 +25,7 @@ module Captive
|
|
|
23
25
|
def self.start(args = ARGV, config = {})
|
|
24
26
|
args = [ "help", args.first ] if args.length == 2 && %w[--help -h].include?(args.last)
|
|
25
27
|
|
|
26
|
-
|
|
27
|
-
when :blocked
|
|
28
|
-
warn "Version #{VERSION} n'est plus supportée. Mettez à jour : brew upgrade captive-studio/tap/captive-cli"
|
|
29
|
-
exit 1
|
|
30
|
-
when :update_available
|
|
31
|
-
warn "⚠ Nouvelle version disponible. Mettez à jour : brew upgrade captive-studio/tap/captive-cli"
|
|
32
|
-
end
|
|
28
|
+
check_for_update unless args.first(2) == %w[auth token]
|
|
33
29
|
super
|
|
34
30
|
rescue AuthError, NotAuthenticatedError, NoTtyError, MissingDependencyError, DashboardError => e
|
|
35
31
|
warn e.message
|
|
@@ -40,6 +36,19 @@ module Captive
|
|
|
40
36
|
exit 1
|
|
41
37
|
end
|
|
42
38
|
|
|
39
|
+
# kubectl appelle `captive auth token` à chaque commande : ni requête réseau en plus, ni
|
|
40
|
+
# avertissement à afficher.
|
|
41
|
+
def self.check_for_update
|
|
42
|
+
case UpdateChecker.new.check
|
|
43
|
+
when :blocked
|
|
44
|
+
warn "Version #{VERSION} n'est plus supportée. Mettez à jour : brew upgrade captive-studio/tap/captive-cli"
|
|
45
|
+
exit 1
|
|
46
|
+
when :update_available
|
|
47
|
+
warn "⚠ Nouvelle version disponible. Mettez à jour : brew upgrade captive-studio/tap/captive-cli"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
private_class_method :check_for_update
|
|
51
|
+
|
|
43
52
|
# ── Subcommands ─────────────────────────────────────────────────────────
|
|
44
53
|
desc "apps SUBCOMMAND", "Gestion des apps"
|
|
45
54
|
subcommand "apps", Commands::AppsCommands
|
|
@@ -53,6 +62,9 @@ module Captive
|
|
|
53
62
|
desc "crons SUBCOMMAND", "Tâches planifiées d'une app"
|
|
54
63
|
subcommand "crons", Commands::CronsCommands
|
|
55
64
|
|
|
65
|
+
desc "auth SUBCOMMAND", "Jeton pour kubectl", hide: true
|
|
66
|
+
subcommand "auth", Commands::AuthCommands
|
|
67
|
+
|
|
56
68
|
# ── Root commands ────────────────────────────────────────────────────────
|
|
57
69
|
map "--version" => :version
|
|
58
70
|
desc "version", "Affiche la version de la CLI"
|
|
@@ -69,7 +81,7 @@ module Captive
|
|
|
69
81
|
Commands::UserLogin.new(
|
|
70
82
|
github: GithubDeviceFlow.new,
|
|
71
83
|
cluster: ClusterEndpoint.new,
|
|
72
|
-
|
|
84
|
+
store: CredentialsStore.new,
|
|
73
85
|
kubeconfig_path: File.expand_path("~/.config/captive/kubeconfig")
|
|
74
86
|
).call
|
|
75
87
|
puts "Authentifié avec succès."
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "auth_token"
|
|
4
|
+
require_relative "../session_token"
|
|
5
|
+
|
|
6
|
+
module Captive
|
|
7
|
+
module Commands
|
|
8
|
+
# `captive auth` : ce dont kubectl a besoin pour s'authentifier, masqué de l'aide.
|
|
9
|
+
class AuthCommands < Thor
|
|
10
|
+
def self.exit_on_failure? = true
|
|
11
|
+
|
|
12
|
+
desc "token", "Imprime un ExecCredential pour kubectl (appelé par le kubeconfig)"
|
|
13
|
+
def token = AuthToken.new(session: SessionToken.new).call
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Captive
|
|
7
|
+
module Commands
|
|
8
|
+
# Le plugin d'authentification exec de kubectl : un ExecCredential sur stdout, et rien d'autre.
|
|
9
|
+
class AuthToken
|
|
10
|
+
def initialize(session:, out: $stdout)
|
|
11
|
+
@session = session
|
|
12
|
+
@out = out
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
@out.puts JSON.generate(exec_credential(@session.current))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def exec_credential(credentials)
|
|
22
|
+
expires_at = credentials.expires_at
|
|
23
|
+
status = { token: credentials.access_token }
|
|
24
|
+
status[:expirationTimestamp] = expires_at.utc.iso8601 if expires_at
|
|
25
|
+
{ apiVersion: "client.authentication.k8s.io/v1", kind: "ExecCredential", status: status }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -9,36 +9,36 @@ module Captive
|
|
|
9
9
|
def open(url) = system(open_cmd, url)
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
def initialize(github:, cluster:,
|
|
12
|
+
def initialize(github:, cluster:, store:, kubeconfig_path:,
|
|
13
13
|
ui: method(:puts), browser: Browser.new("open"))
|
|
14
14
|
@github = github
|
|
15
15
|
@cluster = cluster
|
|
16
|
-
@
|
|
16
|
+
@store = store
|
|
17
17
|
@kubeconfig_path = kubeconfig_path
|
|
18
18
|
@ui = ui
|
|
19
19
|
@browser = browser
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def call
|
|
23
|
-
ensure_dirs
|
|
24
23
|
device = @github.request_device_code
|
|
25
24
|
@browser.open(device[:verification_uri])
|
|
26
25
|
@ui.call("Entre le code sur #{device[:verification_uri]} : #{device[:user_code]}")
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
File.write(@kubeconfig_path, kubeconfig_content(token))
|
|
31
|
-
File.chmod(0o600, @kubeconfig_path)
|
|
26
|
+
credentials = @github.poll_token(device_code: device[:device_code], interval: device[:interval])
|
|
27
|
+
@store.write(credentials)
|
|
28
|
+
write_kubeconfig(@cluster.fetch(credentials.access_token))
|
|
32
29
|
end
|
|
33
30
|
|
|
34
31
|
private
|
|
35
32
|
|
|
36
|
-
def
|
|
37
|
-
|
|
33
|
+
def write_kubeconfig(endpoint)
|
|
34
|
+
FileUtils.mkdir_p(File.dirname(@kubeconfig_path))
|
|
35
|
+
File.write(@kubeconfig_path, kubeconfig_content(endpoint))
|
|
36
|
+
File.chmod(0o600, @kubeconfig_path)
|
|
38
37
|
end
|
|
39
38
|
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
# Pas de jeton dans le kubeconfig : kubectl demande à `captive auth token` un jeton
|
|
40
|
+
# renouvelé quand il le faut.
|
|
41
|
+
def kubeconfig_content(endpoint)
|
|
42
42
|
<<~YAML
|
|
43
43
|
apiVersion: v1
|
|
44
44
|
kind: Config
|
|
@@ -50,7 +50,14 @@ module Captive
|
|
|
50
50
|
users:
|
|
51
51
|
- name: captive-user
|
|
52
52
|
user:
|
|
53
|
-
|
|
53
|
+
exec:
|
|
54
|
+
apiVersion: client.authentication.k8s.io/v1
|
|
55
|
+
command: captive
|
|
56
|
+
args:
|
|
57
|
+
- auth
|
|
58
|
+
- token
|
|
59
|
+
interactiveMode: Never
|
|
60
|
+
provideClusterInfo: false
|
|
54
61
|
contexts:
|
|
55
62
|
- name: captive
|
|
56
63
|
context:
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Captive
|
|
6
|
+
# Les jetons GitHub de l'utilisateur et leurs échéances. Un jeton sans `expires_at` vient d'une
|
|
7
|
+
# GitHub App sans expiration (ou de l'ancien fichier `token`) : il n'est jamais renouvelé.
|
|
8
|
+
Credentials = Data.define(:access_token, :refresh_token, :expires_at, :refresh_token_expires_at) do
|
|
9
|
+
def self.from_github(data, now:)
|
|
10
|
+
new(access_token: data.fetch("access_token"),
|
|
11
|
+
refresh_token: data["refresh_token"],
|
|
12
|
+
expires_at: after(now, data["expires_in"]),
|
|
13
|
+
refresh_token_expires_at: after(now, data["refresh_token_expires_in"]))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.from_json_hash(hash)
|
|
17
|
+
new(access_token: hash.fetch("access_token"),
|
|
18
|
+
refresh_token: hash["refresh_token"],
|
|
19
|
+
expires_at: parse_time(hash["expires_at"]),
|
|
20
|
+
refresh_token_expires_at: parse_time(hash["refresh_token_expires_at"]))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.after(now, seconds) = seconds && (now + seconds.to_i)
|
|
24
|
+
def self.parse_time(value) = value && Time.iso8601(value)
|
|
25
|
+
private_class_method :after, :parse_time
|
|
26
|
+
|
|
27
|
+
def initialize(access_token:, refresh_token: nil, expires_at: nil, refresh_token_expires_at: nil) = super
|
|
28
|
+
|
|
29
|
+
# Moins de `margin` secondes de validité : on renouvelle avant qu'une requête n'échoue.
|
|
30
|
+
def expiring?(now, margin: 300) = expirable? && expires_at - now < margin
|
|
31
|
+
def expirable? = !expires_at.nil?
|
|
32
|
+
def refreshable?(now) = !refresh_token.nil? && (refresh_token_expires_at.nil? || refresh_token_expires_at > now)
|
|
33
|
+
|
|
34
|
+
def to_json_hash
|
|
35
|
+
{
|
|
36
|
+
"access_token" => access_token,
|
|
37
|
+
"refresh_token" => refresh_token,
|
|
38
|
+
"expires_at" => expires_at&.utc&.iso8601,
|
|
39
|
+
"refresh_token_expires_at" => refresh_token_expires_at&.utc&.iso8601,
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require_relative "credentials"
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
|
|
9
|
+
module Captive
|
|
10
|
+
# Où vivent les jetons : `credentials.json` (0600, écrit atomiquement), et en repli l'ancien
|
|
11
|
+
# fichier `token` des postes qui ne se sont pas reconnectés depuis l'arrivée du renouvellement.
|
|
12
|
+
class CredentialsStore
|
|
13
|
+
DIR = File.expand_path("~/.config/captive")
|
|
14
|
+
|
|
15
|
+
def initialize(dir: DIR)
|
|
16
|
+
@path = File.join(dir, "credentials.json")
|
|
17
|
+
@legacy_path = File.join(dir, "token")
|
|
18
|
+
@lock_path = File.join(dir, "credentials.lock")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def read
|
|
22
|
+
return Credentials.from_json_hash(JSON.parse(File.read(@path))) if File.exist?(@path)
|
|
23
|
+
return Credentials.new(access_token: File.read(@legacy_path).strip) if File.exist?(@legacy_path)
|
|
24
|
+
|
|
25
|
+
raise NotAuthenticatedError, "Pas de session active. Lance : captive login"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Un fichier temporaire renommé : un lecteur concurrent voit l'ancien contenu ou le nouveau,
|
|
29
|
+
# jamais un fichier à moitié écrit.
|
|
30
|
+
def write(credentials)
|
|
31
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
32
|
+
tmp = "#{@path}.#{SecureRandom.hex(4)}.tmp"
|
|
33
|
+
File.write(tmp, JSON.pretty_generate(credentials.to_json_hash), perm: 0o600)
|
|
34
|
+
File.rename(tmp, @path)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Verrou inter-processus : deux commandes (ou kubectl et une commande) ne renouvellent pas en
|
|
38
|
+
# même temps un refresh_token à usage unique.
|
|
39
|
+
def exclusively
|
|
40
|
+
FileUtils.mkdir_p(File.dirname(@lock_path))
|
|
41
|
+
File.open(@lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
|
|
42
|
+
lock.flock(File::LOCK_EX)
|
|
43
|
+
yield
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
require "net/http"
|
|
4
4
|
require "uri"
|
|
5
5
|
require "json"
|
|
6
|
-
require_relative "
|
|
6
|
+
require_relative "session_token"
|
|
7
7
|
require_relative "dashboard_response"
|
|
8
8
|
|
|
9
9
|
module Captive
|
|
10
|
-
# Le transport JSON vers l'API du dashboard : jeton GitHub en Bearer
|
|
10
|
+
# Le transport JSON vers l'API du dashboard : jeton GitHub en Bearer (rejoué une fois renouvelé
|
|
11
|
+
# sur un 401), timeouts courts, et
|
|
11
12
|
# erreurs HTTP traduites par DashboardResponse. Les chemins sont relatifs à /api.
|
|
12
13
|
class DashboardApi
|
|
13
14
|
BASE_URL = "https://dashboard.captive.dev"
|
|
@@ -17,7 +18,7 @@ module Captive
|
|
|
17
18
|
|
|
18
19
|
def self.segment(value) = URI.encode_uri_component(value.to_s)
|
|
19
20
|
|
|
20
|
-
def initialize(token:
|
|
21
|
+
def initialize(token: SessionToken.new, base_url: BASE_URL, starter: Net::HTTP.method(:start),
|
|
21
22
|
read_timeout: 15)
|
|
22
23
|
@token = token
|
|
23
24
|
@base_url = base_url
|
|
@@ -34,17 +35,18 @@ module Captive
|
|
|
34
35
|
|
|
35
36
|
def request(verb, path, body = nil)
|
|
36
37
|
uri = URI.parse("#{@base_url}/api#{path}")
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
response = @token.authorize do |token|
|
|
39
|
+
@starter.call(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
40
|
+
open_timeout: 5, read_timeout: @read_timeout) do |http|
|
|
41
|
+
http.request(build(verb, uri, body, token))
|
|
42
|
+
end
|
|
41
43
|
end
|
|
42
44
|
DashboardResponse.new(response).parse
|
|
43
45
|
end
|
|
44
46
|
|
|
45
|
-
def build(verb, uri, body)
|
|
47
|
+
def build(verb, uri, body, token)
|
|
46
48
|
req = VERBS.fetch(verb).new(uri.request_uri)
|
|
47
|
-
req["Authorization"] = "Bearer #{
|
|
49
|
+
req["Authorization"] = "Bearer #{token}"
|
|
48
50
|
req["Accept"] = "application/json"
|
|
49
51
|
if body
|
|
50
52
|
req["Content-Type"] = "application/json"
|
|
@@ -5,22 +5,24 @@ require "uri"
|
|
|
5
5
|
require "json"
|
|
6
6
|
require "time"
|
|
7
7
|
require_relative "errors"
|
|
8
|
+
require_relative "session_token"
|
|
8
9
|
|
|
9
10
|
module Captive
|
|
10
11
|
class DashboardClient
|
|
11
12
|
DASHBOARD_URL = "https://dashboard.captive.dev"
|
|
12
|
-
TOKEN_PATH = File.expand_path("~/.config/captive/token")
|
|
13
13
|
|
|
14
14
|
PAGE_SIZE = 500
|
|
15
15
|
|
|
16
|
-
def
|
|
17
|
-
|
|
16
|
+
def initialize(session: SessionToken.new)
|
|
17
|
+
@session = session
|
|
18
|
+
end
|
|
18
19
|
|
|
20
|
+
def logs(app, start_at: nil, end_at: nil, limit: nil)
|
|
19
21
|
if start_at || end_at
|
|
20
|
-
fetch_all(app,
|
|
22
|
+
fetch_all(app, start_at:, end_at:)
|
|
21
23
|
else
|
|
22
24
|
uri = build_uri(app, limit: limit || PAGE_SIZE)
|
|
23
|
-
response = get(uri
|
|
25
|
+
response = get(uri)
|
|
24
26
|
page, _ = extract_page(handle_response(response))
|
|
25
27
|
page
|
|
26
28
|
end
|
|
@@ -34,20 +36,20 @@ module Captive
|
|
|
34
36
|
body[:command] = command if command
|
|
35
37
|
body[:session] = session if session
|
|
36
38
|
|
|
37
|
-
post(uri,
|
|
39
|
+
post(uri, body)
|
|
38
40
|
end
|
|
39
41
|
|
|
40
42
|
private
|
|
41
43
|
|
|
42
44
|
# Pagine en remontant depuis end_at vers start_at (direction backward côté Loki).
|
|
43
45
|
# Chaque page utilise le ts du log le plus ancien comme prochain curseur `before`.
|
|
44
|
-
def fetch_all(app,
|
|
46
|
+
def fetch_all(app, start_at:, end_at:)
|
|
45
47
|
pages = []
|
|
46
48
|
cursor = end_at
|
|
47
49
|
|
|
48
50
|
loop do
|
|
49
51
|
uri = build_uri(app, start_at:, before: cursor, limit: PAGE_SIZE)
|
|
50
|
-
response = get(uri
|
|
52
|
+
response = get(uri)
|
|
51
53
|
page, has_more = extract_page(handle_response(response))
|
|
52
54
|
|
|
53
55
|
break if page.empty?
|
|
@@ -74,12 +76,6 @@ module Captive
|
|
|
74
76
|
end
|
|
75
77
|
end
|
|
76
78
|
|
|
77
|
-
def read_token
|
|
78
|
-
raise NotAuthenticatedError, "Pas de session active. Lance : captive login" unless File.exist?(TOKEN_PATH)
|
|
79
|
-
|
|
80
|
-
File.read(TOKEN_PATH).strip
|
|
81
|
-
end
|
|
82
|
-
|
|
83
79
|
def build_uri(app, start_at: nil, end_at: nil, before: nil, limit:)
|
|
84
80
|
params = { limit: }
|
|
85
81
|
params[:start_at] = start_at.utc.iso8601(3) if start_at
|
|
@@ -90,21 +86,25 @@ module Captive
|
|
|
90
86
|
uri
|
|
91
87
|
end
|
|
92
88
|
|
|
93
|
-
def get(uri
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
89
|
+
def get(uri)
|
|
90
|
+
@session.authorize do |token|
|
|
91
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
92
|
+
open_timeout: 5, read_timeout: 30) do |http|
|
|
93
|
+
http.get(uri.request_uri, "Authorization" => "Bearer #{token}", "Accept" => "application/json")
|
|
94
|
+
end
|
|
97
95
|
end
|
|
98
96
|
end
|
|
99
97
|
|
|
100
98
|
# Timeouts courts : une console ne doit pas attendre un dashboard qui ne répond pas.
|
|
101
|
-
def post(uri,
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
99
|
+
def post(uri, body)
|
|
100
|
+
@session.authorize do |token|
|
|
101
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
102
|
+
open_timeout: 2, read_timeout: 5) do |http|
|
|
103
|
+
http.post(uri.request_uri, JSON.generate(body),
|
|
104
|
+
"Authorization" => "Bearer #{token}",
|
|
105
|
+
"Accept" => "application/json",
|
|
106
|
+
"Content-Type" => "application/json")
|
|
107
|
+
end
|
|
108
108
|
end
|
|
109
109
|
end
|
|
110
110
|
|
data/lib/captive/errors.rb
CHANGED
|
@@ -9,6 +9,11 @@ module Captive
|
|
|
9
9
|
class ExecError < StandardError; end
|
|
10
10
|
class AuthError < StandardError; end
|
|
11
11
|
class NotAuthenticatedError < StandardError; end
|
|
12
|
+
|
|
13
|
+
# Le refresh_token a expiré ou GitHub le refuse : seul un nouveau `captive login` rétablit l'accès.
|
|
14
|
+
class SessionExpiredError < AuthError
|
|
15
|
+
def initialize(message = "Session expirée. Lance : captive login") = super
|
|
16
|
+
end
|
|
12
17
|
class NoTtyError < StandardError; end
|
|
13
18
|
class MissingDependencyError < StandardError; end
|
|
14
19
|
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "http_client"
|
|
4
|
+
require_relative "credentials"
|
|
4
5
|
|
|
5
6
|
module Captive
|
|
6
7
|
class GithubDeviceFlow
|
|
7
8
|
CLIENT_ID = "Iv23liHr9n6eK4XhwtnP"
|
|
8
9
|
|
|
9
|
-
def initialize(http: HttpClient.new)
|
|
10
|
-
@http
|
|
10
|
+
def initialize(http: HttpClient.new, clock: -> { Time.now })
|
|
11
|
+
@http = http
|
|
12
|
+
@clock = clock
|
|
11
13
|
end
|
|
12
14
|
|
|
13
15
|
RETRIABLE_ERRORS = %w[authorization_pending slow_down].freeze
|
|
@@ -19,7 +21,7 @@ module Captive
|
|
|
19
21
|
data = @http.post("https://github.com/login/oauth/access_token",
|
|
20
22
|
client_id: CLIENT_ID, device_code: device_code,
|
|
21
23
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code")
|
|
22
|
-
return data
|
|
24
|
+
return Credentials.from_github(data, now: @clock.call) if data["access_token"]
|
|
23
25
|
raise DeviceFlowError, data["error"] unless RETRIABLE_ERRORS.include?(data["error"])
|
|
24
26
|
end
|
|
25
27
|
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "credentials_store"
|
|
4
|
+
require_relative "token_refresher"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
|
|
7
|
+
module Captive
|
|
8
|
+
# La source unique du jeton envoyé au dashboard et à kubectl : renouvelé quand il expire dans
|
|
9
|
+
# moins de 5 minutes, ou quand le dashboard le refuse (401).
|
|
10
|
+
class SessionToken
|
|
11
|
+
INVALID = "Token invalide ou expiré. Lance : captive login"
|
|
12
|
+
|
|
13
|
+
def initialize(store: CredentialsStore.new, refresher: TokenRefresher.new, clock: -> { Time.now })
|
|
14
|
+
@store = store
|
|
15
|
+
@refresher = refresher
|
|
16
|
+
@clock = clock
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def current
|
|
20
|
+
now = @clock.call
|
|
21
|
+
credentials = @store.read
|
|
22
|
+
return credentials unless credentials.expiring?(now)
|
|
23
|
+
|
|
24
|
+
renew { |fresh| fresh.expiring?(now) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Après un 401 sur `stale_token` : renouvelle, sauf si un autre processus l'a déjà fait.
|
|
28
|
+
def refresh(stale_token)
|
|
29
|
+
renew { |fresh| fresh.access_token == stale_token }.access_token
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Pour les transports : la requête est rejouée une fois avec un jeton renouvelé sur un 401.
|
|
33
|
+
def authorize
|
|
34
|
+
token = current.access_token
|
|
35
|
+
response = yield token
|
|
36
|
+
return response unless response.code.to_i == 401
|
|
37
|
+
|
|
38
|
+
yield refresh(token)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# Sous verrou, on relit : si un autre processus a renouvelé entre-temps, son jeton suffit.
|
|
44
|
+
def renew(&stale)
|
|
45
|
+
@store.exclusively do
|
|
46
|
+
fresh = @store.read
|
|
47
|
+
next fresh unless stale.call(fresh)
|
|
48
|
+
|
|
49
|
+
@refresher.refresh(refreshable(fresh).refresh_token).tap { |credentials| @store.write(credentials) }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def refreshable(credentials)
|
|
54
|
+
return credentials if credentials.refreshable?(@clock.call)
|
|
55
|
+
raise SessionExpiredError if credentials.expirable?
|
|
56
|
+
|
|
57
|
+
raise AuthError, INVALID
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "http_client"
|
|
4
|
+
require_relative "credentials"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "github_device_flow"
|
|
7
|
+
|
|
8
|
+
module Captive
|
|
9
|
+
# Échange un refresh_token contre de nouveaux jetons. Jeton issu du device flow : pas de
|
|
10
|
+
# client_secret. L'échange est à usage unique et invalide l'ancien couple de jetons.
|
|
11
|
+
class TokenRefresher
|
|
12
|
+
URL = "https://github.com/login/oauth/access_token"
|
|
13
|
+
|
|
14
|
+
def initialize(http: HttpClient.new, clock: -> { Time.now })
|
|
15
|
+
@http = http
|
|
16
|
+
@clock = clock
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def refresh(refresh_token)
|
|
20
|
+
data = @http.post(URL, client_id: GithubDeviceFlow::CLIENT_ID, grant_type: "refresh_token",
|
|
21
|
+
refresh_token: refresh_token)
|
|
22
|
+
raise SessionExpiredError unless data["access_token"]
|
|
23
|
+
|
|
24
|
+
Credentials.from_github(data, now: @clock.call)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/captive/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: captive-cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Captive Studio
|
|
@@ -109,6 +109,8 @@ files:
|
|
|
109
109
|
- lib/captive/cluster_endpoint.rb
|
|
110
110
|
- lib/captive/commands/apps_commands.rb
|
|
111
111
|
- lib/captive/commands/apps_restart.rb
|
|
112
|
+
- lib/captive/commands/auth_commands.rb
|
|
113
|
+
- lib/captive/commands/auth_token.rb
|
|
112
114
|
- lib/captive/commands/console.rb
|
|
113
115
|
- lib/captive/commands/cron_declaration.rb
|
|
114
116
|
- lib/captive/commands/crons_commands.rb
|
|
@@ -124,6 +126,8 @@ files:
|
|
|
124
126
|
- lib/captive/commands/one_off_session.rb
|
|
125
127
|
- lib/captive/commands/run.rb
|
|
126
128
|
- lib/captive/commands/user_login.rb
|
|
129
|
+
- lib/captive/credentials.rb
|
|
130
|
+
- lib/captive/credentials_store.rb
|
|
127
131
|
- lib/captive/dashboard/apps_resource.rb
|
|
128
132
|
- lib/captive/dashboard/env_vars_resource.rb
|
|
129
133
|
- lib/captive/dashboard/scheduled_tasks_resource.rb
|
|
@@ -146,9 +150,10 @@ files:
|
|
|
146
150
|
- lib/captive/presenter/log_line.rb
|
|
147
151
|
- lib/captive/presenter/scheduled_task.rb
|
|
148
152
|
- lib/captive/session_id.rb
|
|
153
|
+
- lib/captive/session_token.rb
|
|
149
154
|
- lib/captive/shell_completions.rb
|
|
150
155
|
- lib/captive/terminal.rb
|
|
151
|
-
- lib/captive/
|
|
156
|
+
- lib/captive/token_refresher.rb
|
|
152
157
|
- lib/captive/ui/banner.rb
|
|
153
158
|
- lib/captive/ui/spinner.rb
|
|
154
159
|
- lib/captive/update_checker.rb
|
data/lib/captive/token_file.rb
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "errors"
|
|
4
|
-
|
|
5
|
-
module Captive
|
|
6
|
-
# Le jeton GitHub écrit par `captive login`, qui authentifie toutes les requêtes au dashboard.
|
|
7
|
-
class TokenFile
|
|
8
|
-
PATH = File.expand_path("~/.config/captive/token")
|
|
9
|
-
|
|
10
|
-
def initialize(path: PATH)
|
|
11
|
-
@path = path
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def read
|
|
15
|
-
raise NotAuthenticatedError, "Pas de session active. Lance : captive login" unless File.exist?(@path)
|
|
16
|
-
|
|
17
|
-
File.read(@path).strip
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
end
|