lex-identity-entra 0.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 +7 -0
- data/.github/workflows/ci.yml +34 -0
- data/.gitignore +11 -0
- data/.rubocop.yml +20 -0
- data/CHANGELOG.md +39 -0
- data/Gemfile +11 -0
- data/README.md +120 -0
- data/lex-identity-entra.gemspec +37 -0
- data/lib/legion/extensions/identity/entra/application/actors/token_refresher.rb +71 -0
- data/lib/legion/extensions/identity/entra/application/client.rb +45 -0
- data/lib/legion/extensions/identity/entra/application/runners/credential.rb +81 -0
- data/lib/legion/extensions/identity/entra/application/scope_registry.rb +15 -0
- data/lib/legion/extensions/identity/entra/application/scopes.rb +47 -0
- data/lib/legion/extensions/identity/entra/application.rb +46 -0
- data/lib/legion/extensions/identity/entra/client.rb +181 -0
- data/lib/legion/extensions/identity/entra/delegated/actors/auth_validator.rb +149 -0
- data/lib/legion/extensions/identity/entra/delegated/actors/token_refresher.rb +91 -0
- data/lib/legion/extensions/identity/entra/delegated/cli/auth.rb +82 -0
- data/lib/legion/extensions/identity/entra/delegated/client.rb +46 -0
- data/lib/legion/extensions/identity/entra/delegated/hooks/auth.rb +23 -0
- data/lib/legion/extensions/identity/entra/delegated/identity.rb +116 -0
- data/lib/legion/extensions/identity/entra/delegated/runners/login.rb +185 -0
- data/lib/legion/extensions/identity/entra/delegated/runners/on_behalf_of.rb +64 -0
- data/lib/legion/extensions/identity/entra/delegated/scope_registry.rb +15 -0
- data/lib/legion/extensions/identity/entra/delegated/scopes.rb +100 -0
- data/lib/legion/extensions/identity/entra/delegated.rb +54 -0
- data/lib/legion/extensions/identity/entra/helpers/account_discovery.rb +85 -0
- data/lib/legion/extensions/identity/entra/helpers/browser_auth.rb +212 -0
- data/lib/legion/extensions/identity/entra/helpers/callback_server.rb +106 -0
- data/lib/legion/extensions/identity/entra/helpers/graph_client.rb +63 -0
- data/lib/legion/extensions/identity/entra/helpers/scope_gate.rb +96 -0
- data/lib/legion/extensions/identity/entra/helpers/scope_registry.rb +72 -0
- data/lib/legion/extensions/identity/entra/helpers/scopes.rb +61 -0
- data/lib/legion/extensions/identity/entra/helpers/token_manager.rb +362 -0
- data/lib/legion/extensions/identity/entra/managed_identity/actors/token_refresher.rb +64 -0
- data/lib/legion/extensions/identity/entra/managed_identity/client.rb +34 -0
- data/lib/legion/extensions/identity/entra/managed_identity/runners/token.rb +61 -0
- data/lib/legion/extensions/identity/entra/managed_identity/scope_registry.rb +15 -0
- data/lib/legion/extensions/identity/entra/managed_identity/scopes.rb +25 -0
- data/lib/legion/extensions/identity/entra/managed_identity.rb +42 -0
- data/lib/legion/extensions/identity/entra/version.rb +11 -0
- data/lib/legion/extensions/identity/entra/workload_identity/actors/token_refresher.rb +64 -0
- data/lib/legion/extensions/identity/entra/workload_identity/client.rb +34 -0
- data/lib/legion/extensions/identity/entra/workload_identity/runners/token.rb +99 -0
- data/lib/legion/extensions/identity/entra/workload_identity/scope_registry.rb +15 -0
- data/lib/legion/extensions/identity/entra/workload_identity/scopes.rb +25 -0
- data/lib/legion/extensions/identity/entra/workload_identity.rb +43 -0
- data/lib/legion/extensions/identity/entra.rb +51 -0
- metadata +178 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ec4cedd630cdcaaf6941ad0fe0c7c9b56b0ab0311f442406b5e32992ea8d15df
|
|
4
|
+
data.tar.gz: a4024c49a2819dfa4a7b12a3243a39bedc93dd8eecf458acffee62f69b4a6909
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7307e9b1ecb97fcbdcdc77672b8a7054fc851eb3289c2050a89e93336057fbdcf0cf160c4df2c33b84b7dc65d6465e036acdb5ec837c688e9d96feffee395996
|
|
7
|
+
data.tar.gz: eb935f1d0ce6e0dac4bf81001100b5a97025e894e884d2d8da4aa031974a9853d8d65f5af09417199967c3cf349a4150ff9ee6118940f028734ef81b6c1330dc
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
branches: [main]
|
|
5
|
+
pull_request:
|
|
6
|
+
schedule:
|
|
7
|
+
- cron: '0 9 * * 1'
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
ci:
|
|
11
|
+
uses: LegionIO/.github/.github/workflows/ci.yml@main
|
|
12
|
+
|
|
13
|
+
excluded-files:
|
|
14
|
+
uses: LegionIO/.github/.github/workflows/excluded-files.yml@main
|
|
15
|
+
|
|
16
|
+
security:
|
|
17
|
+
uses: LegionIO/.github/.github/workflows/security-scan.yml@main
|
|
18
|
+
|
|
19
|
+
version-changelog:
|
|
20
|
+
uses: LegionIO/.github/.github/workflows/version-changelog.yml@main
|
|
21
|
+
|
|
22
|
+
dependency-review:
|
|
23
|
+
uses: LegionIO/.github/.github/workflows/dependency-review.yml@main
|
|
24
|
+
|
|
25
|
+
stale:
|
|
26
|
+
if: github.event_name == 'schedule'
|
|
27
|
+
uses: LegionIO/.github/.github/workflows/stale.yml@main
|
|
28
|
+
|
|
29
|
+
release:
|
|
30
|
+
needs: [ci, excluded-files]
|
|
31
|
+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
32
|
+
uses: LegionIO/.github/.github/workflows/release.yml@main
|
|
33
|
+
secrets:
|
|
34
|
+
rubygems-api-key: ${{ secrets.RUBYGEMS_API_KEY }}
|
data/.gitignore
ADDED
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
inherit_gem:
|
|
2
|
+
rubocop-legion: config/lex.yml
|
|
3
|
+
|
|
4
|
+
Style/ModuleFunction:
|
|
5
|
+
Exclude:
|
|
6
|
+
- 'lib/legion/extensions/identity/entra/helpers/token_manager.rb'
|
|
7
|
+
- 'lib/legion/extensions/identity/entra/helpers/graph_client.rb'
|
|
8
|
+
- 'lib/legion/extensions/identity/entra/helpers/account_discovery.rb'
|
|
9
|
+
|
|
10
|
+
Legion/Extension/EveryActorRequiresTime:
|
|
11
|
+
Enabled: false
|
|
12
|
+
|
|
13
|
+
ThreadSafety/ClassInstanceVariable:
|
|
14
|
+
Exclude:
|
|
15
|
+
- 'lib/legion/extensions/identity/helpers/'
|
|
16
|
+
- 'lib/legion/extensions/identity/entra/helpers/'
|
|
17
|
+
- 'lib/legion/extensions/identity/entra/helpers/'
|
|
18
|
+
- 'lib/legion/extensions/identity/entra/client.rb'
|
|
19
|
+
- 'lib/legion/extensions/identity/entra/helpers/scope_gate.rb'
|
|
20
|
+
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.3.0] - 2026-05-14
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Delegated token persistence to HashiCorp Vault at `kv/data/users/<identity>/entra/delegated/auth`; disk file used only as fallback when Vault is unavailable.
|
|
9
|
+
- Automatic backfill: existing disk tokens are migrated to Vault on next load and the disk file is deleted once Vault write succeeds.
|
|
10
|
+
- `delete_local` cleans up the on-disk token file whenever Vault becomes authoritative.
|
|
11
|
+
- `AuthValidator` calls `Legion::Identity::Broker.register_provider` after successful auth so any extension can call `Legion::Identity::Broker.token_for(:entra_delegated, qualifier: :delegated)`.
|
|
12
|
+
- `AuthValidator` calls `Legion::Identity::Resolver.upgrade!` after auth to promote the Entra-verified identity into `Legion::Identity::Process` state.
|
|
13
|
+
- MD5 scope fingerprint stored with token across all backends; mismatch or missing fingerprint forces re-authentication.
|
|
14
|
+
- In-memory token store as tertiary fallback (vault → disk → memory).
|
|
15
|
+
- Delegated scopes expanded to full org-granted permission set: Teams, Chat, Channel, OnlineMeetings, OneNote, SharePoint, Yammer, Files, Presence, and core OpenID/profile scopes.
|
|
16
|
+
- OAuth callback page auto-closes after 10-second JavaScript countdown.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- Vault read skipped at boot until process identity is trusted (prevents 403 during resolver race).
|
|
20
|
+
- Vault read/write now use `Legion::Crypt.get`/`Legion::Crypt.write` via the `kv` mount directly, matching the policy path.
|
|
21
|
+
- `AuthValidator` delay reduced from 90s to 9s.
|
|
22
|
+
|
|
23
|
+
## [0.2.0] - 2026-05-07
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- Browser OAuth with PKCE, local callback handling, and device-code fallback for delegated Entra auth.
|
|
27
|
+
- Microsoft identity OAuth runner for client credentials, authorization code, device code, and refresh-token grants.
|
|
28
|
+
- CLI auth entrypoint and manifest metadata for `legion lex exec entra auth login/status`.
|
|
29
|
+
- Refresh-aware token persistence with Vault, local file, and Broker fallback lookup.
|
|
30
|
+
- Multi-account Entra discovery for delegated, secondary, and privileged account token qualifiers.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
- `resolve_all` now uses discovered Entra token qualifiers instead of wrapping only the default delegated account.
|
|
34
|
+
- `provide_token` includes qualifier and scope metadata from the persisted token record.
|
|
35
|
+
|
|
36
|
+
## [0.1.0] - 2026-04-24
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
- Initial Entra identity provider scaffold with cached-token Graph `/me` resolution.
|
data/Gemfile
ADDED
data/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# lex-identity-entra
|
|
2
|
+
|
|
3
|
+
LegionIO identity provider extension for Microsoft Entra ID (Azure AD). Implements the unified
|
|
4
|
+
identity provider contract for delegated (user), application (client credentials), managed identity,
|
|
5
|
+
and workload identity authentication patterns.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Delegated auth** — OAuth2 PKCE browser flow with local callback server; device-code fallback
|
|
10
|
+
- **Token persistence** — Vault-first (`kv/data/users/<identity>/entra/<qualifier>/auth`), disk fallback, in-memory fallback; disk file deleted once Vault write succeeds
|
|
11
|
+
- **Scope fingerprinting** — MD5 fingerprint of active scopes stored with token; any scope change forces re-authentication on next boot
|
|
12
|
+
- **Identity integration** — `AuthValidator` upgrades `Legion::Identity::Process` via `Resolver.upgrade!` and registers with `Legion::Identity::Broker` for cross-extension token access
|
|
13
|
+
- **Application credentials** — client credentials flow for service-to-service auth
|
|
14
|
+
- **Managed Identity** — Azure IMDS token acquisition for hosted workloads
|
|
15
|
+
- **Workload Identity** — federated credential support for Kubernetes and CI/CD
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
Add to your `Gemfile`:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
gem 'lex-identity-entra'
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Configuration
|
|
26
|
+
|
|
27
|
+
```yaml
|
|
28
|
+
# ~/.legionio/settings/identity.yml
|
|
29
|
+
identity:
|
|
30
|
+
entra:
|
|
31
|
+
auth:
|
|
32
|
+
tenant_id: "your-tenant-id"
|
|
33
|
+
client_id: "your-client-id"
|
|
34
|
+
delegated:
|
|
35
|
+
browser_auth:
|
|
36
|
+
auto_authenticate: false # set true to open browser automatically at boot
|
|
37
|
+
callback_timeout: 120
|
|
38
|
+
token:
|
|
39
|
+
refresh_buffer: 60
|
|
40
|
+
refresh_interval: 900
|
|
41
|
+
scopes:
|
|
42
|
+
enabled_categories:
|
|
43
|
+
- microsoft_graph
|
|
44
|
+
- teams
|
|
45
|
+
- one_note
|
|
46
|
+
- sharepoint
|
|
47
|
+
- azure_communication_services
|
|
48
|
+
- yammer
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
### Delegated (user) authentication
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# Via CLI
|
|
57
|
+
legion lex exec entra auth login
|
|
58
|
+
legion lex exec entra auth status
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The `AuthValidator` actor fires 9 seconds after boot. If `auto_authenticate: true` is set,
|
|
62
|
+
it opens a browser window automatically. Otherwise trigger login via the CLI.
|
|
63
|
+
|
|
64
|
+
### Accessing tokens from another extension
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
token = Legion::Identity::Broker.token_for(:entra_delegated, qualifier: :delegated)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Application (client credentials)
|
|
71
|
+
|
|
72
|
+
```yaml
|
|
73
|
+
identity:
|
|
74
|
+
entra:
|
|
75
|
+
application:
|
|
76
|
+
tenant_id: "your-tenant-id"
|
|
77
|
+
client_id: "your-client-id"
|
|
78
|
+
client_secret: "your-client-secret" # or use Vault
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Scope categories
|
|
82
|
+
|
|
83
|
+
| Category | Description |
|
|
84
|
+
|----------|-------------|
|
|
85
|
+
| `microsoft_graph` | Core Graph API: User.Read, Files, Devices, OpenID scopes |
|
|
86
|
+
| `teams` | Teams, Chat, Channel, OnlineMeetings, Presence, Activity |
|
|
87
|
+
| `one_note` | OneNote notebook read/write |
|
|
88
|
+
| `sharepoint` | SharePoint/OneDrive files and sites |
|
|
89
|
+
| `azure_communication_services` | Teams calls and chat management |
|
|
90
|
+
| `yammer` | Viva Engage / Yammer communities and conversations |
|
|
91
|
+
|
|
92
|
+
## Token storage
|
|
93
|
+
|
|
94
|
+
| Backend | Path | Priority |
|
|
95
|
+
|---------|------|----------|
|
|
96
|
+
| HashiCorp Vault | `kv/data/users/<identity>/entra/delegated/auth` | 1 (preferred) |
|
|
97
|
+
| Local disk | `~/.legionio/tokens/entra_delegated.json` | 2 (fallback, deleted when Vault succeeds) |
|
|
98
|
+
| Memory | In-process store | 3 (runtime fallback) |
|
|
99
|
+
|
|
100
|
+
## Identity provider contract
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
{
|
|
104
|
+
canonical_name: "jdoe", # normalized from onPremisesSamAccountName or mailNickname
|
|
105
|
+
kind: :human,
|
|
106
|
+
source: :entra_delegated,
|
|
107
|
+
provider_identity: "object-id-guid",
|
|
108
|
+
profile: { ... } # full Graph /me response
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
bundle install
|
|
116
|
+
bundle exec rspec
|
|
117
|
+
bundle exec rubocop
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**GitHub**: https://github.com/LegionIO/lex-identity-entra
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/legion/extensions/identity/entra/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'lex-identity-entra'
|
|
7
|
+
spec.version = Legion::Extensions::Identity::Entra::VERSION
|
|
8
|
+
spec.authors = ['Esity']
|
|
9
|
+
spec.email = ['matthewdiverson@gmail.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'LEX Identity: Entra ID (Azure AD) provider'
|
|
12
|
+
spec.description = 'LegionIO identity provider that resolves Entra ID (Azure AD) identity ' \
|
|
13
|
+
'via Microsoft Graph API into the unified identity contract'
|
|
14
|
+
spec.homepage = 'https://github.com/LegionIO/lex-identity-entra'
|
|
15
|
+
spec.license = 'MIT'
|
|
16
|
+
spec.required_ruby_version = '>= 3.4'
|
|
17
|
+
|
|
18
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
19
|
+
spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-identity-entra'
|
|
20
|
+
spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-identity-entra'
|
|
21
|
+
spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-identity-entra'
|
|
22
|
+
spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-identity-entra/issues'
|
|
23
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
24
|
+
|
|
25
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
26
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
|
27
|
+
end
|
|
28
|
+
spec.require_paths = ['lib']
|
|
29
|
+
|
|
30
|
+
# Core framework dependencies
|
|
31
|
+
spec.add_dependency 'concurrent-ruby', '>= 1.2'
|
|
32
|
+
spec.add_dependency 'faraday', '>= 2.0'
|
|
33
|
+
spec.add_dependency 'legion-crypt', '>= 1.5.13'
|
|
34
|
+
spec.add_dependency 'legion-json', '>= 1.2.1'
|
|
35
|
+
spec.add_dependency 'legion-logging', '>= 1.5.3'
|
|
36
|
+
spec.add_dependency 'legion-settings', '>= 1.3.14'
|
|
37
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Identity
|
|
6
|
+
module Entra
|
|
7
|
+
module Application
|
|
8
|
+
module Actor
|
|
9
|
+
class TokenRefresher < Legion::Extensions::Actors::Every
|
|
10
|
+
DEFAULT_REFRESH_INTERVAL = 2700
|
|
11
|
+
|
|
12
|
+
def runner_class = self.class
|
|
13
|
+
def runner_function = 'manual'
|
|
14
|
+
def use_runner? = false
|
|
15
|
+
def check_subtask? = false
|
|
16
|
+
def generate_task? = false
|
|
17
|
+
def run_now? = false
|
|
18
|
+
|
|
19
|
+
def time
|
|
20
|
+
Legion::Settings.dig(:identity, :entra, :application, :token, :refresh_interval) ||
|
|
21
|
+
DEFAULT_REFRESH_INTERVAL
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def enabled? # rubocop:disable Legion/Extension/ActorEnabledSideEffects
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def manual
|
|
29
|
+
log.debug('Application TokenRefresher tick')
|
|
30
|
+
data = Legion::Extensions::Identity::Entra::Helpers::TokenManager.token_data(:application, refresh: false)
|
|
31
|
+
|
|
32
|
+
if data && !Legion::Extensions::Identity::Entra::Helpers::TokenManager.expired?(data)
|
|
33
|
+
log.debug('Application token still valid')
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
log.info('Application token nearing expiry, re-acquiring')
|
|
38
|
+
auth_settings = Legion::Extensions::Identity::Entra::Helpers::TokenManager.settings_auth
|
|
39
|
+
runner = Object.new.extend(Legion::Extensions::Identity::Entra::Application::Runners::Credential)
|
|
40
|
+
result = runner.acquire_token(
|
|
41
|
+
tenant_id: auth_settings[:tenant_id],
|
|
42
|
+
client_id: auth_settings[:client_id],
|
|
43
|
+
client_secret: auth_settings[:client_secret]
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
body = result&.dig(:result)
|
|
47
|
+
unless body&.dig(:access_token)
|
|
48
|
+
log.warn('Application token re-acquisition failed')
|
|
49
|
+
return
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
Legion::Extensions::Identity::Entra::Helpers::TokenManager.save_token(
|
|
53
|
+
:application,
|
|
54
|
+
access_token: body[:access_token],
|
|
55
|
+
expires_in: body[:expires_in],
|
|
56
|
+
scopes: body[:scope] || 'https://graph.microsoft.com/.default',
|
|
57
|
+
tenant_id: auth_settings[:tenant_id],
|
|
58
|
+
client_id: auth_settings[:client_id]
|
|
59
|
+
)
|
|
60
|
+
Legion::Extensions::Identity::Entra::Client.reset!(pattern: :application)
|
|
61
|
+
log.info('Application token refreshed successfully')
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
log.error("Application TokenRefresher: #{e.message}")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Identity
|
|
6
|
+
module Entra
|
|
7
|
+
module Application
|
|
8
|
+
class Client < Legion::Extensions::Identity::Entra::Client
|
|
9
|
+
def pattern = :application
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def authenticate
|
|
14
|
+
settings = auth_settings
|
|
15
|
+
return unless settings[:tenant_id] && settings[:client_id] && settings[:client_secret]
|
|
16
|
+
|
|
17
|
+
requested = Legion::Extensions::Identity::Entra::Helpers::Scopes.resolve(pattern: :application)
|
|
18
|
+
registry.record_requested(requested)
|
|
19
|
+
|
|
20
|
+
runner = Object.new.extend(Legion::Extensions::Identity::Entra::Application::Runners::Credential)
|
|
21
|
+
result = runner.acquire_token(
|
|
22
|
+
tenant_id: settings[:tenant_id],
|
|
23
|
+
client_id: settings[:client_id],
|
|
24
|
+
client_secret: settings[:client_secret]
|
|
25
|
+
)
|
|
26
|
+
body = result&.dig(:result)
|
|
27
|
+
return unless body&.dig(:access_token)
|
|
28
|
+
|
|
29
|
+
granted = body[:scope] || 'https://graph.microsoft.com/.default'
|
|
30
|
+
registry.record_granted(granted)
|
|
31
|
+
Legion::Extensions::Identity::Entra::Helpers::TokenManager.save_token(
|
|
32
|
+
:application,
|
|
33
|
+
access_token: body[:access_token],
|
|
34
|
+
expires_in: body[:expires_in],
|
|
35
|
+
scopes: granted,
|
|
36
|
+
tenant_id: settings[:tenant_id],
|
|
37
|
+
client_id: settings[:client_id]
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'legion/extensions/identity/entra/helpers/scopes'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module Extensions
|
|
8
|
+
module Identity
|
|
9
|
+
module Entra
|
|
10
|
+
module Application
|
|
11
|
+
module Runners
|
|
12
|
+
module Credential
|
|
13
|
+
include Legion::Logging::Helper
|
|
14
|
+
include Legion::Settings::Helper
|
|
15
|
+
|
|
16
|
+
DEFAULT_SCOPE = 'https://graph.microsoft.com/.default'
|
|
17
|
+
|
|
18
|
+
def acquire_token(tenant_id:, client_id:, client_secret:,
|
|
19
|
+
scope: DEFAULT_SCOPE, **)
|
|
20
|
+
log.debug("Credential.acquire_token: tenant=#{tenant_id}")
|
|
21
|
+
result = credential_post(tenant_id,
|
|
22
|
+
grant_type: 'client_credentials',
|
|
23
|
+
client_id: client_id,
|
|
24
|
+
client_secret: client_secret,
|
|
25
|
+
scope: scope)
|
|
26
|
+
log.info('Credential.acquire_token: token acquired') if result[:access_token]
|
|
27
|
+
{ result: result }
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
handle_exception(e, level: :error, operation: 'application.credential.acquire_token')
|
|
30
|
+
{ error: 'request_failed', description: e.message }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def acquire_token_with_certificate(tenant_id:, client_id:, client_assertion:,
|
|
34
|
+
scope: DEFAULT_SCOPE, **)
|
|
35
|
+
log.debug("Credential.acquire_token_with_certificate: tenant=#{tenant_id}")
|
|
36
|
+
result = credential_post(tenant_id,
|
|
37
|
+
grant_type: 'client_credentials',
|
|
38
|
+
client_id: client_id,
|
|
39
|
+
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
|
40
|
+
client_assertion: client_assertion,
|
|
41
|
+
scope: scope)
|
|
42
|
+
log.info('Credential.acquire_token_with_certificate: token acquired') if result[:access_token]
|
|
43
|
+
{ result: result }
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
handle_exception(e, level: :error, operation: 'application.credential.acquire_token_with_certificate')
|
|
46
|
+
{ error: 'request_failed', description: e.message }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def credential_post(tenant_id, form)
|
|
50
|
+
log.debug("Credential.credential_post: tenant=#{tenant_id}")
|
|
51
|
+
response = credential_connection(tenant_id).post('oauth2/v2.0/token',
|
|
52
|
+
URI.encode_www_form(form.transform_keys(&:to_s)))
|
|
53
|
+
body = response.body.to_s.empty? ? {} : json_load(response.body)
|
|
54
|
+
unless response.success?
|
|
55
|
+
body[:error] ||= "http_#{response.status}"
|
|
56
|
+
body[:error_description] ||= response.reason_phrase
|
|
57
|
+
log.debug("Credential.credential_post: error=#{body[:error]} status=#{response.status}")
|
|
58
|
+
end
|
|
59
|
+
body
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def credential_connection(tenant_id)
|
|
65
|
+
Faraday.new(url: "https://login.microsoftonline.com/#{tenant_id}/") do |f|
|
|
66
|
+
f.headers['Accept'] = 'application/json'
|
|
67
|
+
f.headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
68
|
+
f.options.open_timeout = 5
|
|
69
|
+
f.options.timeout = 15
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
|
|
74
|
+
Legion::Extensions::Helpers.const_defined?(:Lex, false)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/identity/entra/helpers/scope_registry'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Identity
|
|
8
|
+
module Entra
|
|
9
|
+
module Application
|
|
10
|
+
ScopeRegistry = Legion::Extensions::Identity::Entra::Helpers::ScopeRegistry.new(pattern: :application)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Identity
|
|
6
|
+
module Entra
|
|
7
|
+
module Application
|
|
8
|
+
module Scopes
|
|
9
|
+
CATEGORIES = {
|
|
10
|
+
microsoft_graph: %w[
|
|
11
|
+
User.Read.All
|
|
12
|
+
Application.Read.All
|
|
13
|
+
Directory.Read.All
|
|
14
|
+
Group.Read.All
|
|
15
|
+
Mail.Read
|
|
16
|
+
Mail.ReadBasic.All
|
|
17
|
+
Mail.Send
|
|
18
|
+
Calendars.Read
|
|
19
|
+
Sites.Read.All
|
|
20
|
+
Files.Read.All
|
|
21
|
+
TeamMember.Read.All
|
|
22
|
+
Channel.ReadBasic.All
|
|
23
|
+
ChannelMessage.Read.All
|
|
24
|
+
Chat.Read.All
|
|
25
|
+
ChatMessage.Read.All
|
|
26
|
+
OnlineMeetings.Read.All
|
|
27
|
+
OnlineMeetingTranscript.Read.All
|
|
28
|
+
OnlineMeetingRecording.Read.All
|
|
29
|
+
CallRecords.Read.All
|
|
30
|
+
],
|
|
31
|
+
one_note: %w[
|
|
32
|
+
Notes.Read.All
|
|
33
|
+
Notes.ReadWrite.All
|
|
34
|
+
],
|
|
35
|
+
sharepoint: %w[
|
|
36
|
+
Sites.Read.All
|
|
37
|
+
Sites.ReadWrite.All
|
|
38
|
+
Files.Read.All
|
|
39
|
+
Files.ReadWrite.All
|
|
40
|
+
]
|
|
41
|
+
}.freeze
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'application/scopes'
|
|
4
|
+
require_relative 'application/scope_registry'
|
|
5
|
+
require_relative 'application/runners/credential'
|
|
6
|
+
require_relative 'application/actors/token_refresher'
|
|
7
|
+
require_relative 'application/client'
|
|
8
|
+
|
|
9
|
+
module Legion
|
|
10
|
+
module Extensions
|
|
11
|
+
module Identity
|
|
12
|
+
module Entra
|
|
13
|
+
module Application
|
|
14
|
+
extend Legion::Extensions::Core if Legion::Extensions.const_defined?(:Core, false)
|
|
15
|
+
|
|
16
|
+
def self.identity_provider? = false
|
|
17
|
+
def self.remote_invocable? = false
|
|
18
|
+
|
|
19
|
+
def self.default_settings
|
|
20
|
+
{
|
|
21
|
+
logger: { level: 'info' },
|
|
22
|
+
workers: 1,
|
|
23
|
+
runners: {},
|
|
24
|
+
auth: {
|
|
25
|
+
tenant_id: nil,
|
|
26
|
+
client_id: nil,
|
|
27
|
+
client_secret: nil,
|
|
28
|
+
certificate: nil
|
|
29
|
+
},
|
|
30
|
+
scopes: {
|
|
31
|
+
enabled_categories: [:microsoft_graph],
|
|
32
|
+
category_overrides: {}
|
|
33
|
+
},
|
|
34
|
+
token: {
|
|
35
|
+
vault_path: nil,
|
|
36
|
+
local_token_path: nil,
|
|
37
|
+
refresh_buffer: 60,
|
|
38
|
+
refresh_interval: 2700
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|