Skip to content

Commit f5e8b8a

Browse files
authored
Support EntraID as alt OAuth provider (#758)
1 parent cea2bac commit f5e8b8a

7 files changed

Lines changed: 292 additions & 91 deletions

File tree

.env.template

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,20 @@ APP_TOOLBOX_DB_PASSWORD=toolbox
1616
#APP_TOOLBOX_USE_H2=true
1717
#APP_TOOLBOX_DB_CREATE=update
1818

19+
# OAuth provider - set to AUTH_ZERO (default), ENTRA_ID, or NONE (local username/password login)
20+
#APP_TOOLBOX_OAUTH_PROVIDER=AUTH_ZERO
21+
1922
# Bootstrap admin
20-
# The _USER var can be set on its own to grant an OAuth (Auth0) sourced user admin rights in local
21-
# development. This is supported by Toolbox's use of Hoist Core DefaultRoleService.
23+
# The _USER var can be set on its own to grant an OAuth-sourced user admin rights in local
24+
# development. This is supported by Toolbox's use of Hoist Core `DefaultRoleService`.
2225
#APP_TOOLBOX_BOOTSTRAP_ADMIN_USER=
2326

2427
# If the _PASSWORD var is also set, Toolbox will create a password-enabled user in its user
25-
# database that can be used when Auth0 is not available. Pair this with the env below to disable
26-
# the OAuth flow entirely and present a form-based login. This is especially useful when testing
27-
# the client on a private IP address via `yarn startWithHoistAndIp` for on-device mobile testing.
28+
# database that can be used when Auth0 is not available.
29+
# Pair with `APP_TOOLBOX_OAUTH_PROVIDER=NONE` to disable the OAuth flow entirely and present a
30+
# form-based login, useful when testing the client when offline or on a private IP address via
31+
# `yarn startWithHoistAndIp` for on-device mobile testing.
2832
#APP_TOOLBOX_BOOTSTRAP_ADMIN_PASSWORD=
29-
#APP_TOOLBOX_USE_OAUTH=false
3033

3134
# Email support
3235
#APP_TOOLBOX_SMTP_HOST=

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## v7.0-SNAPSHOT - unreleased
44

5+
### New Features
6+
* Enabled support for testing OAuth flows against Azure / Entra ID, in addition to Auth0. To support switching, the prior `useOauth` instance config has been replaced with a new `oauthProvider` config - aka `APP_TOOLBOX_OAUTH_PROVIDER` in your `.env` file for local development.
7+
58
## v6.1.0 - 2025-02-14
69

710
### Libraries

client-app/src/core/AuthModel.ts

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import {HoistAuthModel, managed, PlainObject, XH} from '@xh/hoist/core';
2-
import {AuthZeroClient, AuthZeroClientConfig} from '@xh/hoist/security/authzero/AuthZeroClient';
2+
import {AuthZeroClient, AuthZeroClientConfig} from '@xh/hoist/security/authzero';
3+
import {MsalClient, MsalClientConfig} from '@xh/hoist/security/msal';
34

45
/**
5-
* Toolbox uses Auth0 for OAuth authentication. Here we are overriding the base {@link HoistAuthModel} to load
6-
* OAuth-related soft-config from the Toolbox server, initialize an {@link AuthZeroClient} instance, kick-off the
7-
* Oauth flow, and setup default fetch headers to include an id token.
6+
* Toolbox's implementation of {@link HoistAuthModel} contract for handling authentication.
7+
*
8+
* This example is atypical of most application implementations in that it supports a fallback
9+
* option for local username/password login, for offline or other local testing scenarios where
10+
* OAuth is undesired, as well as flows against either Auth0 (default) or Azure Entra ID.
811
*/
912
export class AuthModel extends HoistAuthModel {
1013
@managed
11-
client: AuthZeroClient;
14+
client: AuthZeroClient | MsalClient;
1215

1316
override async completeAuthAsync(): Promise<boolean> {
1417
this.setMaskMsg('Authenticating...');
@@ -28,34 +31,19 @@ export class AuthModel extends HoistAuthModel {
2831
return ret;
2932
}
3033

31-
// Otherwise we proceed with the primary OAuth flow by constructing and initializing an AuthZeroClient, one of
32-
// the OAuth implementations supported out-of-the-box by Hoist.
33-
const audience = 'toolbox.xh.io';
34-
this.client = new AuthZeroClient({
35-
idScopes: ['profile'],
36-
// Toolbox does not actually need any access tokens -- just a test
37-
accessTokens: {
38-
test: {
39-
scopes: ['profile'],
40-
fetchMode: 'eager',
41-
audience
42-
}
43-
},
44-
// This config works along with the accessToken requested above - by passing the same
45-
// audience to our interactive login requests, they return access/refresh tokens that
46-
// are immediately usable.
47-
audience,
48-
...(config as AuthZeroClientConfig)
49-
});
34+
// Otherwise proceed with the primary OAuth flow by constructing and initializing one of the two
35+
// supported client implementations - either Auth0 (default) or MSAL (also supported, for testing OAuth
36+
// against Microsoft Entra ID).
37+
this.client = this.createClient(config);
5038
await this.client.initAsync();
5139

52-
// With the client initialized, we tell FetchService to pass the Auth0 supplied id token (a JWT) via a custom
40+
// With the client initialized, we tell FetchService to pass the ID token (a JWT) via a custom
5341
// header on any local/relative requests going back to Toolbox Grails server.
5442
XH.fetchService.addDefaultHeaders(async opts => {
5543
if (opts.url.startsWith('http')) return null;
5644

5745
const idToken = await this.client.getIdTokenAsync();
58-
return idToken ? {'x-xh-idt': idToken.value} : null;
46+
return idToken ? {Authorization: `Bearer ${idToken.value}`} : null;
5947
});
6048

6149
// Finally, make a request to check the auth-status on the server - that call will include the id token header
@@ -74,6 +62,30 @@ export class AuthModel extends HoistAuthModel {
7462
await this.client?.logoutAsync();
7563
}
7664

65+
private createClient(config: PlainObject): AuthZeroClient | MsalClient {
66+
if (config.provider === 'AUTH_ZERO') {
67+
const audience = 'toolbox.xh.io';
68+
return new AuthZeroClient({
69+
idScopes: ['profile'],
70+
// Toolbox does not actually need any access tokens -- just a test
71+
accessTokens: {
72+
test: {
73+
scopes: ['profile'],
74+
fetchMode: 'eager',
75+
audience
76+
}
77+
},
78+
// This config works along with the accessToken requested above - by passing the same
79+
// audience to our interactive login requests, they return access/refresh tokens that
80+
// are immediately usable.
81+
audience,
82+
...(config as AuthZeroClientConfig)
83+
});
84+
} else {
85+
return new MsalClient(config as MsalClientConfig);
86+
}
87+
}
88+
7789
// Update overall load mask message to provide an indication that this auth flow is processing.
7890
private setMaskMsg(msg: string) {
7991
XH.appContainerModel.initializingLoadMaskMessage = msg;

grails-app/init/io/xh/toolbox/BootStrap.groovy

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,12 @@ class BootStrap implements LogSupport {
6666

6767
logInfo("""
6868
\n
69-
______ ______ ______ __ ______ ______ __ __
70-
/\\__ _\\ /\\ __ \\ /\\ __ \\ /\\ \\ /\\ == \\ /\\ __ \\ /\\_\\_\\_\\
71-
\\/_/\\ \\/ \\ \\ \\/\\ \\ \\ \\ \\/\\ \\ \\ \\ \\____ \\ \\ __< \\ \\ \\/\\ \\ \\/_/\\_\\/_
72-
\\ \\_\\ \\ \\_____\\ \\ \\_____\\ \\ \\_____\\ \\ \\_____\\ \\ \\_____\\ /\\_\\/\\_\\
73-
\\/_/ \\/_____/ \\/_____/ \\/_____/ \\/_____/ \\/_____/ \\/_/\\/_/
74-
\n
69+
______ ______ ______ __ ______ ______ __ __
70+
/\\__ _\\ /\\ __ \\ /\\ __ \\ /\\ \\ /\\ == \\ /\\ __ \\ /\\_\\_\\_\\
71+
\\/_/\\ \\/ \\ \\ \\/\\ \\ \\ \\ \\/\\ \\ \\ \\ \\____ \\ \\ __< \\ \\ \\/\\ \\ \\/_/\\_\\/_
72+
\\ \\_\\ \\ \\_____\\ \\ \\_____\\ \\ \\_____\\ \\ \\_____\\ \\ \\_____\\ /\\_\\/\\_\\
73+
\\/_/ \\/_____/ \\/_____/ \\/_____/ \\/_____/ \\/_____/ \\/_/\\/_/
74+
\n
7575
${appName} v${appVersion}${buildLabel}${appEnvironment}
7676
\n
7777
""")
@@ -126,6 +126,16 @@ class BootStrap implements LogSupport {
126126
clientVisible: false,
127127
groupName: 'Toolbox - Example Apps'
128128
],
129+
entraIdConfig: [
130+
valueType: 'json',
131+
defaultValue: [
132+
clientId: '5d933976-8fe4-40fc-bc13-b9d239a2efe5',
133+
tenantId: '51759969-dc12-46ec-a1e9-2532084dc881'
134+
],
135+
clientVisible: false,
136+
groupName: 'Auth',
137+
note: 'OAuth config for the Toolbox app registered at our Azure Entra ID tenant. For testing Entra ID as an alternate OAuth provider.'
138+
],
129139
fileManagerStoragePath: [
130140
valueType: 'string',
131141
defaultValue: '/var/tmp/xh-toolbox',

grails-app/services/io/xh/toolbox/security/Auth0Service.groovy renamed to grails-app/services/io/xh/toolbox/security/AuthZeroTokenService.groovy

Lines changed: 41 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,31 @@ import static io.xh.hoist.json.JSONParser.parseObject
1212
import static java.lang.System.currentTimeMillis
1313

1414
/**
15-
* Decodes and validates ID tokens issues by Auth0, the OAuth provider for Toolbox.
15+
* Decodes and validates ID tokens issued by Auth0, the primary/default OAuth provider for Toolbox.
1616
*/
17-
class Auth0Service extends BaseService {
17+
class AuthZeroTokenService extends BaseService {
1818

1919
static clearCachesConfigs = ['auth0Config']
2020

21+
AuthenticationService authenticationService
2122
ConfigService configService
2223

2324
private JsonWebKeySet _jwks
2425

2526
Map getClientConfig() {
26-
configService.getMap('auth0Config', [:])
27+
return [
28+
provider: 'AUTH_ZERO',
29+
*: config
30+
]
2731
}
2832

2933
void init() {
3034
super.init()
35+
3136
// Fetch JWKS eagerly so it's ready for potential burst of initial requests after startup.
32-
getJsonWebKeySet()
37+
if (authenticationService.oauthProvider == 'AUTH_ZERO') {
38+
getJsonWebKeySet()
39+
}
3340
}
3441

3542
TokenValidationResult validateToken(String token) {
@@ -39,44 +46,38 @@ class Auth0Service extends BaseService {
3946
}
4047

4148
try {
42-
logTrace('Validating token', token)
43-
44-
def jws = new JsonWebSignature()
45-
jws.setCompactSerialization(token)
46-
47-
def selector = new VerificationJwkSelector(),
48-
jwk = selector.select(jws, jsonWebKeySet.jsonWebKeys)
49-
if (!jwk?.key) throw new RuntimeException('Unable to select valid key for token from loaded JWKS')
50-
51-
jws.setKey(jwk.key)
52-
if (!jws.verifySignature()) throw new RuntimeException('Token failed signature validation')
53-
def payload = parseObject(jws.payload)
54-
55-
logDebug('Token parsed successfully', [
56-
email: payload.email,
57-
name: payload.name,
58-
sub: payload.sub,
59-
aud: payload.aud,
60-
exp: payload.exp
61-
])
62-
63-
if (payload.aud != clientId) {
64-
throw new RuntimeException('Token aud value does not match expected value from clientId')
65-
}
66-
if (payload.exp * 1000L < currentTimeMillis()) {
67-
throw new RuntimeException('Token has expired')
49+
withTrace(['Validating token', token]) {
50+
def jws = new JsonWebSignature()
51+
jws.setCompactSerialization(token)
52+
53+
def selector = new VerificationJwkSelector(),
54+
jwk = selector.select(jws, jsonWebKeySet.jsonWebKeys)
55+
if (!jwk?.key) throw new RuntimeException('Unable to select valid key for token from loaded JWKS')
56+
57+
jws.setKey(jwk.key)
58+
if (!jws.verifySignature()) throw new RuntimeException('Token failed signature validation')
59+
def payload = parseObject(jws.payload)
60+
61+
logDebug('Token parsed successfully', payload)
62+
63+
if (payload.aud != clientId) {
64+
throw new RuntimeException('Token aud value does not match expected value from clientId')
65+
}
66+
if (payload.exp * 1000L < currentTimeMillis()) {
67+
throw new RuntimeException('Token has expired')
68+
}
69+
if (!payload.sub || !payload.email) {
70+
throw new RuntimeException('Token is missing sub or email')
71+
}
72+
73+
return new TokenValidationResult(
74+
email: payload.email,
75+
name: payload.name,
76+
picture: payload.picture
77+
)
6878
}
69-
if (!payload.sub || !payload.email) {
70-
throw new RuntimeException('Token is missing sub or email')
71-
}
72-
73-
return new TokenValidationResult(
74-
email: payload.email,
75-
name: payload.name,
76-
picture: payload.picture
77-
)
7879
} catch (Exception e) {
79-
logError('Exception parsing JWT', e)
80+
logDebug('Exception parsing JWT', e)
8081
return null
8182
}
8283
}

grails-app/services/io/xh/toolbox/security/AuthenticationService.groovy

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.xh.toolbox.security
22

3+
import grails.compiler.GrailsCompileStatic
34
import grails.gorm.transactions.ReadOnly
45
import io.xh.hoist.security.BaseAuthenticationService
56
import io.xh.toolbox.user.User
@@ -10,22 +11,47 @@ import javax.servlet.http.HttpServletRequest
1011

1112
import static io.xh.hoist.util.InstanceConfigUtils.getInstanceConfig
1213

13-
class AuthenticationService extends BaseAuthenticationService {
14-
15-
Auth0Service auth0Service
14+
/**
15+
* Toolbox's implementation of Hoist's {@link BaseAuthenticationService} contract for handling
16+
* authentication. This example is atypical of most application implementations of this service
17+
* in that it supports a fallback option for local username/password login as well as OAuth.
18+
*
19+
* Use the `oauthProvider` instance config to set the OAuth provider to use, or `NONE` to disable.
20+
*
21+
* It can also delegate to either {@link AuthZeroTokenService} or {@link EntraIdTokenService} to
22+
* validate JWTs when in OAuth mode, to support testing flows against either provider.
23+
*/
24+
@GrailsCompileStatic
25+
class AuthenticationService extends BaseAuthenticationService {
26+
27+
AuthZeroTokenService authZeroTokenService
28+
EntraIdTokenService entraIdTokenService
1629
UserService userService
1730

18-
private String AUTH_HEADER = 'x-xh-idt'
31+
private String AUTH_HEADER = 'Authorization'
1932

2033
AuthenticationService() {
2134
super()
2235
whitelistURIs.push('/gitHub/webhookTrigger')
2336
}
2437

2538
Map getClientConfig() {
26-
useOAuth ?
27-
[useOAuth: true, *: auth0Service.getClientConfig()] :
28-
[useOAuth: false]
39+
switch (oauthProvider) {
40+
case 'AUTH_ZERO':
41+
return [useOAuth: true, *: authZeroTokenService.getClientConfig()];
42+
case 'ENTRA_ID':
43+
return [useOAuth: true, *: entraIdTokenService.getClientConfig()];
44+
default:
45+
return [useOAuth: false]
46+
}
47+
}
48+
49+
String getOauthProvider() {
50+
getInstanceConfig('oauthProvider') ?: 'AUTH_ZERO'
51+
}
52+
53+
boolean getUseOAuth() {
54+
getInstanceConfig('oauthProvider') != 'NONE'
2955
}
3056

3157
/**
@@ -37,11 +63,14 @@ class AuthenticationService extends BaseAuthenticationService {
3763
protected boolean completeAuthentication(HttpServletRequest request, HttpServletResponse response) {
3864
if (!useOAuth) return true
3965

40-
String token = request.getHeader(AUTH_HEADER)
66+
String token = request.getHeader(AUTH_HEADER)?.replace('Bearer ', '')
4167
TokenValidationResult tokenResult = null
4268

4369
if (token) {
44-
tokenResult = auth0Service.validateToken(token)
70+
tokenResult = oauthProvider == 'AUTH_ZERO' ?
71+
authZeroTokenService.validateToken(token) :
72+
entraIdTokenService.validateToken(token)
73+
4574
} else {
4675
logTrace("Unable to validate inbound request - no token presented in header")
4776
}
@@ -76,6 +105,16 @@ class AuthenticationService extends BaseAuthenticationService {
76105
}
77106

78107

108+
@Override
109+
Map getAdminStats() {
110+
return [
111+
*: super.getAdminStats(),
112+
oauthProvider: oauthProvider,
113+
clientConfig: clientConfig
114+
]
115+
}
116+
117+
79118
//------------------------
80119
// Implementation
81120
//------------------------
@@ -85,8 +124,4 @@ class AuthenticationService extends BaseAuthenticationService {
85124
return user?.checkPassword(password) ? user : null
86125
}
87126

88-
private static boolean getUseOAuth() {
89-
getInstanceConfig('useOAuth') != 'false'
90-
}
91-
92127
}

0 commit comments

Comments
 (0)