-
Notifications
You must be signed in to change notification settings - Fork 89
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
Conversation
✅ Deploy Preview for brilliant-pasca-3e80ec canceled.
|
0c50082
to
4aa0a03
Compare
@@ -98,17 +99,15 @@ describe('introspection', (): void => { | |||
}) | |||
}) | |||
|
|||
describe('validateTokenInfo', (): void => { | |||
test('returns valid token info', async (): Promise<void> => { | |||
describe('validateTokenAccess', (): void => { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
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 | ||
} | ||
} |
There was a problem hiding this comment.
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?
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 | |
} |
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 | ||
} | ||
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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' |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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!
packages/auth/src/access/utils.ts
Outdated
) { | ||
return false | ||
} else if ( | ||
restOfRequestAccessItem[key as keyof typeof restOfRequestAccessItem] !== |
There was a problem hiding this comment.
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
restOfRequestAccessItem[key as keyof typeof restOfRequestAccessItem] !== | |
requestAccessItemValue !== |
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
type: type as any, | ||
actions: [action], | ||
identifier: identifier ?? undefined |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
identifier: identifier ?? undefined | |
identifier |
access: [ | ||
toOpenPaymentsAccess( | ||
requestType, | ||
requestAction, | ||
ctx.walletAddressUrl | ||
) | ||
] |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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].
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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?
for (const accessItem of access) { | ||
const { access: grantAccess } = token.grant | ||
if ( | ||
!grantAccess.find((grantAccessItem) => | ||
compareRequestAndGrantAccessItems( | ||
accessItem, | ||
toOpenPaymentsAccess(grantAccessItem) | ||
) | ||
) | ||
) { | ||
return undefined | ||
} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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[]) => |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
However, looking at
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 👍
a98360f
to
c3bc37b
Compare
return false | ||
}) | ||
}) | ||
if (tokenInfo.access.length > 1) { |
There was a problem hiding this comment.
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) | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
do we need
) | |
).toBe(true) |
or does expect()
just work?
There was a problem hiding this comment.
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
packages/auth/src/access/utils.ts
Outdated
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 | ||
} | ||
}) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
for (const accessItem of access) { | ||
const { access: grantAccess } = token.grant | ||
if ( | ||
!grantAccess.find((grantAccessItem) => | ||
compareRequestAndGrantAccessItems( | ||
accessItem, | ||
toOpenPaymentsAccess(grantAccessItem) | ||
) | ||
) | ||
) { | ||
return undefined | ||
} |
There was a problem hiding this comment.
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[]) => |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
However, looking at
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.
d030e41
to
90d82ba
Compare
There was a problem hiding this 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 |
There was a problem hiding this comment.
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?
Feel free to re-request after fixing the test |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚀
Changes proposed in this pull request
access
parameter to token introspection requestauth
service now verifies if providedaccess
is sufficiently allowed by grant retrieved during token introspectiontoken-introspection
extends capability of verifying a token has sufficient access for a requested access scope.Context
Fixes #835.
Checklist
fixes #number