Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(auth): inspect access during token introspection #2788

Merged
merged 18 commits into from
Jul 30, 2024

Conversation

njlie
Copy link
Contributor

@njlie njlie commented Jun 26, 2024

Changes proposed in this pull request

  • Adds access parameter to token introspection request
  • auth service now verifies if provided access is sufficiently allowed by grant retrieved during token introspection
  • token-introspection extends capability of verifying a token has sufficient access for a requested access scope.

Context

Fixes #835.

Checklist

  • Related issues linked using fixes #number
  • Tests added/updated
  • Documentation added
  • Make sure that all checks pass
  • Bruno collection updated

@github-actions github-actions bot added type: tests Testing related pkg: backend Changes in the backend package. type: source Changes business logic pkg: auth Changes in the GNAP auth package. labels Jun 26, 2024
Copy link

netlify bot commented Jun 26, 2024

Deploy Preview for brilliant-pasca-3e80ec canceled.

Name Link
🔨 Latest commit 017699f
🔍 Latest deploy log https://app.netlify.com/sites/brilliant-pasca-3e80ec/deploys/66a2da4a63d64200081a7a62

@njlie njlie force-pushed the nl/835/token-introspection-access branch from 0c50082 to 4aa0a03 Compare July 12, 2024 23:53
@njlie njlie marked this pull request as ready for review July 15, 2024 18:01
@@ -98,17 +99,15 @@ describe('introspection', (): void => {
})
})

describe('validateTokenInfo', (): void => {
test('returns valid token info', async (): Promise<void> => {
describe('validateTokenAccess', (): void => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you add a case where the validation fails?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ended up removing it, actually

Comment on lines 59 to 70
function toOpenPaymentsAccess(
type: AccessType,
action: RequestAction,
identifier?: string
): AccessItem {
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: type as any,
actions: [action],
identifier: identifier ?? undefined
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i tried to make this fully typesafe and it got brutal 😆

Effectively the same, but perhaps a little cleaner?

Suggested change
function toOpenPaymentsAccess(
type: AccessType,
action: RequestAction,
identifier?: string
): AccessItem {
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: type as any,
actions: [action],
identifier: identifier ?? undefined
}
}
function toOpenPaymentsAccess(
type: AccessType,
action: RequestAction,
identifier?: string
): AccessItem {
return {
type: type,
actions: [action],
identifier: identifier ?? undefined
} as AccessItem
}

Comment on lines 129 to 142
if (
requestAction === AccessAction.Read &&
(access as Access).actions.includes(AccessAction.ReadAll)
) {
ctx.accessAction = AccessAction.ReadAll
} else if (
requestAction === AccessAction.List &&
(access as Access).actions.includes(AccessAction.ListAll)
) {
ctx.accessAction = AccessAction.ListAll
} else {
ctx.accessAction = requestAction
}

Copy link
Contributor

@BlairCurrey BlairCurrey Jul 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we de-duplicate this logic some by returning RequestAction | undefined from validateTokenAccess instead of the AccessItem? Then using that to set ctx.accessAction. Just looks like we're doing the same thing in validateTokenAccess.

Looks like the only other place we use the AccessItem is to set the grant limits but I think the tokenInfo should work fine for that instead, if we wanted.

Copy link
Contributor

@BlairCurrey BlairCurrey Jul 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

er... maybe not. I didn't fully understand the type of tokenInfo. Dont think we can use that for grant limit since we won't know which access item we're dealing with. Guess we could return both access and accessAction from validateToken if we cared to.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we can just return a filtered version of access with a single item from the introspection endpoint, and have the RS just assume that the lone item in that array is the relevant access.

Then the backend can just check if there is a Read-All or List-All action in the list if the request for a Read or List action, respectively.

From the Resource Server Spec (emphasis mine):

access (array of strings/objects):
REQUIRED. The access rights associated with this access token. This MUST be in the format described in the Section 8 of [GNAP]. This array MAY be filtered or otherwise limited for consumption by the identified RS, including being an empty array, indicating that the token has no explicit access rights that can be disclosed to the RS.

@@ -0,0 +1,47 @@
import { isDeepStrictEqual } from 'util'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how did I not realize this was in the node standard library? Cool.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just discovered it when completing this PR. I knew there surely was a library that could handle this but I wasn't expecting a node-native solution either!

) {
return false
} else if (
restOfRequestAccessItem[key as keyof typeof restOfRequestAccessItem] !==
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could use requestAccessItemValue, since it was already initialized on L28

Suggested change
restOfRequestAccessItem[key as keyof typeof restOfRequestAccessItem] !==
requestAccessItemValue !==

BlairCurrey
BlairCurrey previously approved these changes Jul 18, 2024
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: type as any,
actions: [action],
identifier: identifier ?? undefined
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
identifier: identifier ?? undefined
identifier

Comment on lines 102 to 108
access: [
toOpenPaymentsAccess(
requestType,
requestAction,
ctx.walletAddressUrl
)
]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this to be an array?

Copy link
Contributor Author

@njlie njlie Jul 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GNAP Resource Server spec specifies that this be an array.

access (array of strings/objects):
OPTIONAL. The minimum access rights required to fulfill the request. This MUST be in the format described in Section 8 of [GNAP].

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, makes sense!

return true
}
return tokenAccess.actions.find((tokenAction: AccessAction) => {
if (isActiveTokenInfo(tokenInfo) && tokenAction === access.action) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to check for isActiveTokenInfo earlier? ie the two ifs before this find -> is it valid to return true without checking isActiveTokenInfo first?

Comment on lines 93 to 104
for (const accessItem of access) {
const { access: grantAccess } = token.grant
if (
!grantAccess.find((grantAccessItem) =>
compareRequestAndGrantAccessItems(
accessItem,
toOpenPaymentsAccess(grantAccessItem)
)
)
) {
return undefined
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to understand this correctly, in this implementation, the access will consist of an array with a single item of { identifier access type }. We then compare this object to the existing grantAccessItem.
And even though accessItem will never have limits, compareRequestAndGrantAccessItems checks if all the other keys are equivalent, correct?

Copy link
Contributor Author

@njlie njlie Jul 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that's pretty much it. I wrote it to be able to handle multiple access items as an input, though.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. Just as an edge case, we are parsing access.limits correctly from Postgres, right?
Don't want to run into an instance where for example, the limit.debit/receiveAmount.accessScale gets parsed out as a string and then we can't properly compare it to it being a number in the token introspection request

@@ -42,7 +48,8 @@ export async function createAccessTokenService({
return {
getByManagementId: (managementId: string) =>
getByManagementId(managementId),
introspect: (tokenValue: string) => introspect(deps, tokenValue),
introspect: (tokenValue: string, access?: AccessItem[]) =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will/should access ever be undefined?

Copy link
Contributor Author

@njlie njlie Jul 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's optional in the GNAP spec. It's not required in the OpenAPI spec for the token-introspection package, but we certainly could make it required. Payments are pretty sensitive entities, I'd be in favor of making it required as part of Open Payments.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can change that later if we wanted, for now it's ok I think.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edit, I think if we are doing this, ie returning the single relevant access item, we should make this required

return false
})
})
if (tokenInfo.access.length > 1) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whether or not we need this check depends on if it is decided that the token introspection endpoint will always return ONLY the request's matching access item in the response.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like GNAP is flexible on this as well:

access (array of strings/objects):
REQUIRED. The access rights associated with this access token. This MUST be in the format described in the Section 8 of [GNAP]. This array MAY be filtered or otherwise limited for consumption by the identified RS, including being an empty array, indicating that the token has no explicit access rights that can be disclosed to the RS.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However, looking at

https://github.com/interledger/rafiki/pull/2788/files#diff-4c4c1504c8978d5fcd7a7bfa8210903c7dada6ec793ac0ad8e895fda2f0a5115R120-R122

since it looks like its "possible" to return multiple accesses, we should keep the original behavior of allowing multiple? Or we make accessItem required in the auth introspection route handler.

Copy link
Contributor Author

@njlie njlie Jul 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's what I decided - the introspection endpoint on the auth server will always return whatever access rights were presented in the request if the token is valid, no more, no less.

The backend service can then expect the response to have exactly one item since the middleware only ever presents one access during the introspection request.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@njlie makes sense to me 👍

@njlie njlie force-pushed the nl/835/token-introspection-access branch from a98360f to c3bc37b Compare July 23, 2024 22:47
return false
})
})
if (tokenInfo.access.length > 1) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like GNAP is flexible on this as well:

access (array of strings/objects):
REQUIRED. The access rights associated with this access token. This MUST be in the format described in the Section 8 of [GNAP]. This array MAY be filtered or otherwise limited for consumption by the identified RS, including being an empty array, indicating that the token has no explicit access rights that can be disclosed to the RS.

compareRequestAndGrantAccessItems(
requestAccessItem,
toOpenPaymentsAccess(grantAccessItemSuperAction)
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need

Suggested change
)
).toBe(true)

or does expect() just work?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it works, updated just to be sure

Comment on lines 27 to 44
Object.keys(restOfRequestAccessItem).forEach((key) => {
const requestAccessItemValue =
restOfRequestAccessItem[key as keyof typeof restOfRequestAccessItem]
if (
typeof requestAccessItemValue === 'object' &&
!isDeepStrictEqual(
requestAccessItemValue,
restOfgrantAccessItem[key as keyof typeof restOfgrantAccessItem]
)
) {
return false
} else if (
restOfRequestAccessItem[key as keyof typeof restOfRequestAccessItem] !==
restOfgrantAccessItem[key as keyof typeof restOfgrantAccessItem]
) {
return false
}
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I wouldn't mind comparing keys explicitly here (ie requestAccessItem.type === grantAccessItem.type), since we don't have too many, but this works as well

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I decided to update this to compare the keys explicitly. I added a test in light of this comment to ensure that only the requested access rights get returned, and it wouldn't work without explicit key comparison.

Comment on lines 93 to 104
for (const accessItem of access) {
const { access: grantAccess } = token.grant
if (
!grantAccess.find((grantAccessItem) =>
compareRequestAndGrantAccessItems(
accessItem,
toOpenPaymentsAccess(grantAccessItem)
)
)
) {
return undefined
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. Just as an edge case, we are parsing access.limits correctly from Postgres, right?
Don't want to run into an instance where for example, the limit.debit/receiveAmount.accessScale gets parsed out as a string and then we can't properly compare it to it being a number in the token introspection request

@@ -42,7 +48,8 @@ export async function createAccessTokenService({
return {
getByManagementId: (managementId: string) =>
getByManagementId(managementId),
introspect: (tokenValue: string) => introspect(deps, tokenValue),
introspect: (tokenValue: string, access?: AccessItem[]) =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can change that later if we wanted, for now it's ok I think.

return false
})
})
if (tokenInfo.access.length > 1) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However, looking at

https://github.com/interledger/rafiki/pull/2788/files#diff-4c4c1504c8978d5fcd7a7bfa8210903c7dada6ec793ac0ad8e895fda2f0a5115R120-R122

since it looks like its "possible" to return multiple accesses, we should keep the original behavior of allowing multiple? Or we make accessItem required in the auth introspection route handler.

@njlie njlie force-pushed the nl/835/token-introspection-access branch from d030e41 to 90d82ba Compare July 24, 2024 23:37
@njlie njlie requested review from mkurapov and BlairCurrey July 24, 2024 23:42
mkurapov
mkurapov previously approved these changes Jul 25, 2024
Copy link
Contributor

@mkurapov mkurapov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! just a minor comment

const token = await AccessToken.query(deps.knex)
.findOne({ value: tokenValue })
.withGraphFetched('grant.access')

let foundAccessItem: Access | undefined
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be defined here or just in the L95 loop?

@mkurapov
Copy link
Contributor

Feel free to re-request after fixing the test

Copy link
Contributor

@mkurapov mkurapov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀

@njlie njlie merged commit d6d65ff into main Jul 30, 2024
36 of 42 checks passed
@njlie njlie deleted the nl/835/token-introspection-access branch July 30, 2024 15:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
pkg: auth Changes in the GNAP auth package. pkg: backend Changes in the backend package. type: source Changes business logic type: tests Testing related
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Check request access during token introspection
3 participants